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

SDK расширений v1

Core предоставляет нейтральные точки расширения для кабинета, инструкций, внешних услуг, наград и бэкапов. Конкретные игры, билетики, внешние VPN-сервисы и облачные хранилища реализуются отдельно. Пользователь видит обычные разделы продукта с названиями и оформлением расширения; каталог плагинов доступен только администратору.

Plugin.extensions(ctx) возвращает ExtensionContributions из bot.plugins.extensions. Поля: guides, resources, products, jobs, events, backups, storage, view_policy, permissions. По умолчанию все пусты. Регистрация одного владельца атомарна; конфликт идентификаторов останавливает запуск. Владелец — Plugin.name; локальный идентификатор соответствует [a-z][a-z0-9_-]{0,63}, составной ключ — owner:id. Имя core зарезервировано.

Хук объявляет контракты без побочных эффектов. Он вызывается и при offline restore, где PluginContext содержит только settings: зависимости для runtime-операций нужно получать внутри callback из его контекста. Обычный setup при восстановлении не запускается. Старая версия API плагинов остаётся 1: новые методы имеют пустые defaults.

В core_compatibility.mode = "capabilities" пакет запрашивает необходимые контракты из requires: user_ui, install_guides, external_orders, durable_jobs, rewards, backups — каждый версии 1. support_connector: 1 сохраняется. Неизвестная или несовпадающая версия отвергается до установки. Обновление пакета использует прежний подписанный ZIP, immutable digest и смену поколения backend/worker.

Это доверенный исполняемый код в процессе Core, не песочница. Подпись подтверждает происхождение; администратор отвечает за доверие издателю. Объявленные permissions проверяют корректность использования SDK, но не изолируют Python или JavaScript. Не модифицируйте глобальные Core registries и внутренние таблицы напрямую.

Карточки, подразделы внутри существующих разделов ЛК и админки, замена блоков, порядок и отказоустойчивость: UI composition v1.

У подписанного manifest есть отдельный frontend.user:

{
  "entry": "customer/index.js",
  "styles": ["customer/style.css"],
  "assets": [],
  "pages": [{
    "id": "services", "view": "services", "label": "Мои устройства",
    "i18nKey": "devices_title", "icon": "device", "order": 100,
    "navigation": "primary"
  }],
  "slots": [{
    "id": "summary", "view": "summary", "label": "Мои устройства",
    "target": "user.home.cards", "order": 100
  }]
}

Все файлы должны присутствовать в подписанном files. Пользователь получает только явно перечисленные entry/styles/assets, не файлы административного модуля. Можно выпустить пакет только с frontend.user, без административного frontend.entry. Страницы и view идентифицируются [a-z][a-z0-9-]{1,63}. Иконки: star, gift, device, home, support, settings, shield. navigation: "hidden" сохраняет прямую ссылку, скрывая пункт меню. view_policy(UserContext, view_id) решает видимость на сервере; ошибка закрывает соответствующее представление. Проверки видимости не заменяют авторизацию HTTP-операций плагина.

Маршрут страницы: /extensions/<owner>/<id>, с учётом route prefix кабинета. Core не добавляет заголовок «Плагины», карточку или обязательное оформление страницы. Плагин рендерит содержимое внутри host; оболочка сохраняет навигацию, включая back и прямую ссылку. На мобильных устройствах длинная навигация прокручивается. Слоты: user.home.cards, user.subscription.actions (экран подписки/главная), user.profile.actions (настройки), user.install.blocks (инструкции).

ESM exports: mountView(view, element, props) -> instance, unmountView(instance) и необязательный updateView(instance, props). Без updateView изменение контекста приводит к повторному монтированию. props = {host, language, context}; context содержит screen и снимок подписки. Используйте SDK host.version === 1, t, request, navigate, back, notify, confirm, createOrder, paymentMethods, checkout, orders, order, resource. request("/path") обращается только к /api/plugins/<owner>/path, с текущей сессией. Плагин регистрирует свои маршруты через setup_web и сам проверяет пользователя, владение данными и права на каждую операцию. HTTP-ответы используют {ok: true, ...}.

Используйте CSS-переменные Core (--bg, --text, --muted, --accent, --border, --radius) и ограничивайте селекторы собственным контейнером. Host убирает ссылки на styles при unmount; сам модуль обязан очищать timers, listeners и свои порталы. Виджет с ошибкой показывает нейтральное сообщение и не блокирует остальные экраны. Не кладите ключи подключения и секреты в manifest, JavaScript или публичный context.

GuideProvider(id, resolve) получает UserContext(session, user_id, language) и возвращает GuideContribution(config, order) или None. config — полный документ действующего v1 формата инструкций с произвольными платформами. Core валидирует его, добавляет префикс owner--provider-- к платформам и SVG-идентификаторам и объединяет с выбранным источником Panel/config. Базовые инструкции не заменяются. Итоговый идентификатор платформы не длиннее 64 символов; конфликтующий фрагмент пропускается. Администратор может скрыть provider или изменить порядок в разделе операций плагина. Персональные фрагменты не попадают в публичные share-инструкции.

ResourceProvider(id, resolve) принимает пользователя и непрозрачный ключ ресурса. Callback обязан проверить принадлежность ключа этому пользователю и вернуть ResourceResult(body, content_type, filename). /api/extensions/resource?provider=owner:id&key=... доступен только авторизованному незаблокированному пользователю; результат выдаётся как вложение с no-store, до 2 MiB, без HTML/SVG исполнения. Для загрузки из модуля предпочтителен host.resource(provider, key), возвращающий Blob. В инструкции можно использовать ссылку на этот маршрут; opaque key не должен сам содержать private key.

ProductProvider(id, quote, fulfill, revoke, status=None, terms_versions=(1,)):

  • quote(UserContext, options) возвращает ProductQuote: title, amount_minor, currency, terms, version, expires_at. Сумма целочисленная, в минимальных единицах валюты; срок котировки — от текущего времени до 24 часов. Quote не меняет данные. Terms сохраняются неизменно и видны покупателю: не помещайте туда секреты.
  • fulfill(OperationContext, OrderSnapshot) создаёт/продлевает услугу и возвращает FulfillmentResult(reference, data). Data публичны; конфигурационные ключи выдавайте через resource provider. Продление моделируется отдельным заказом с прежней reference в terms, проверенной на сервере во время quote.
  • revoke(OperationContext, OrderSnapshot) подтверждает отзыв услуги. Он должен уметь найти операцию по order.id даже без сохранённой reference после сетевого сбоя.
  • status(UserContext, OrderSnapshot) возвращает публичное текущее состояние; вызывается через host.order(id, true) для исполненного заказа.

extensions.commerce.create_order работает в транзакции вызывающего кода. host.createOrder(local_product, options, idempotencyKey) использует Core HTTP API. Повтор ключа с теми же параметрами возвращает исходный заказ и исходную цену; изменённые параметры дают conflict. Checkout использует snapshot, не сумму браузера. Core принимает внутренний баланс и совместимые разовые платёжные адаптеры; recurring адаптеры не подключаются автоматически. host.paymentMethods(orderId) возвращает доступные способы для этой цены/валюты, включая проверку минимальной суммы и настроек баланса. RUB-заказ нельзя оплатить Stars с неявной конвертацией. Адаптеры с ценой, которую определяет внешний каталог, исключены. Один заказ связан ровно с одним счётом. При отменённом/несозданном счёте создайте новый заказ с новым ключом; Core не заменяет старый счёт, чтобы запоздавшая оплата не выдала услугу дважды.

Payment state: pending → paid → refunding → refunded. Fulfillment state: pending → fulfilling → fulfilled; возврат переводит её в revoking → revoked. Подтверждённая оплата принимается и после expiry quote/отключения плагина; выдача остаётся в сохранённом задании до включения совместимого обработчика. Обновлённый provider обязан сохранять старые terms_versions, пока есть незакрытые заказы.

extensions.commerce.request_refund — привилегированная команда, не открытый пользовательский API. Администратор использует вкладку «Операции»: сначала отзыв, затем ровно одно зачисление на внутренний баланс. Это не возврат через эквайринг. В админке такой возврат доступен только для валюты внутреннего баланса; другие валюты требуют отдельного процесса возврата у платёжного провайдера через SDK. SDK to_balance=False предназначен для отдельно подтверждённого внешнего возврата; он не инициирует платёж у провайдера. Повтор с другим способом возврата запрещён. Активная выдача временно блокирует возврат. Неизвестный результат внешнего вызова нужно согласовать по order.id; внешний сервис должен поддерживать идемпотентный отзыв/запрет позднего создания. Локальная lease не отменяет уже отправленный запрос.

Стабильные импорты: bot.plugins.extensions.jobs.{enqueue,publish} и bot.plugins.extensions.rewards.{Reward,grant}. Они не коммитят транзакцию. Записывайте списание билета, результат игры, резерв приза и grant/enqueue одним commit. Выбор результата выполняет сервер плагина. При исключении откатывайте всю бизнес-транзакцию. Core не определяет правила игр или остатки призов.

JobHandler(id, run, timeout_seconds=60, max_attempts=10, interval_seconds=None) обрабатывает (OperationContext, payload) -> dict. Периодические задания используют UTC-слоты, минимальный интервал 60 секунд, без воспроизведения пропущенных слотов. Задания сохраняются в PostgreSQL; обработчики работают вне долгой транзакции с lease, повторными попытками и защитой записи результата от устаревшего исполнителя. Доставка at least once: внешний вызов обязан использовать operation_id как ключ идемпотентности. Максимальный JSON payload/result — 256 KiB. Ошибка обработчика сохраняется как стабильный код, без текста с возможными секретами.

DurableSubscription(event, job) подписывает job на событие. publish сохраняет доставки каждому активному подписчику в транзакции изменения. payment.succeeded публикуется Core при фиксации успешной оплаты с payment_db_id, user_id, amount, currency, provider, sale_mode; получатели фиксируются в этой же транзакции. Повторы с одинаковым event/event_id не создают новых доставок; иной payload даёт conflict. Событие означает завершённую оплату, а не успешную выдачу внешней услуги. Фильтруйте sale_mode и funding source согласно правилам начисления плагина.

При отключении владельца работа блокируется без потери данных и без расходования попыток. Исчерпанные попытки видны администратору и могут быть запущены повторно с прежним operation_id. Запуск старого поколения не выполняет новые операции. Удаление аккаунта очищает его локальные задания/заказы; объединение переносит их на целевой аккаунт и сохраняет обе покупки при конфликте ключей. Операция в работе временно блокирует перенос/удаление. Для идентичности используйте актуальный OperationContext.user_id; идентификаторы внутри старого payload — снимок события. Отзыв внешних ресурсов перед удалением аккаунта остаётся обязанностью интеграции.

grant(..., owner, user_id, idempotency_key, reward=Reward(...)) требует явного permission rewards.balance, rewards.days, rewards.traffic или rewards.codes. Amount: минимальные единицы валюты, целые дни, байты либо дни персонального кода. Balance/codes выдаются транзакционно; days/traffic меняют локальные права и ставят повторяемую синхронизацию абсолютного состояния в Panel. Дни/трафик требуют активной подписки. Персональный код — одноразовый, с фиксированным количеством дней. Дни не продлевают отдельно купленные пакеты устройств. Внешние custom-призы реализуйте собственными jobs. Повтор ключа не создаёт второй grant.

BackupContributor(id, version, collect, validate, restore) работает в отдельном каталоге extensions/<owner>/<id>. Collect пишет файлы и возвращает JSON metadata; validate проверяет их без изменений; restore восстанавливает их идемпотентно. Ссылки, выход из каталога и опасные имена не допускаются. Таблицы плагина уже входят в общий PostgreSQL dump. Manifest фиксирует версии всех активных плагинов и версию каждого контракта. Восстановление требует совпадающих версий и всех contributors; проверка происходит до pg_restore. Для старого архива без extensions поведение прежнее.

Стабильный модуль bot.plugins.extensions.backups: create_backup(ctx), list_archives(storage), download_archive(ctx, storage=..., name=...), delete_archive(storage, name). Скачивание импортирует и проверяет архив Core, возвращает локальное имя, не запускает восстановление. Имена внешнего хранилища не используются как локальные пути. Дополнительное расписание — обычный JobHandler.

BackupStorageProvider(id, upload, download, list_archives, delete) подключает доставку готовых архивов. Core сохраняет отдельную копию до успешной отправки, чтобы retention не удалил ещё не доставленный архив. Повтор upload с тем же именем должен быть идемпотентным. Неуспешные операции доступны в админке. После аварии между commit и удалением копии возможен оставшийся файл в .extension-uploads; его можно удалить, убедившись, что соответствующая операция succeeded. Шифрование/ключи — ответственность хранилища; download возвращает расшифрованный обычный архив Core.

Offline restore проверяет contributors на candidate database, а файловые callbacks выполняет после переключения БД. Сбой callback оставляет journal recovery_required: не запускайте сервисы до завершения идемпотентного восстановления файлов. Общей атомарной транзакции PostgreSQL, локальных файлов и внешнего API не существует. Не храните единственную копию ключа расшифровки внутри зашифрованного архива.

Перед поставкой плагина проверяйте повтор запроса/webhook, обрыв после внешнего вызова до commit, отключение при незавершённой оплате, старые quote versions, возврат без reference, чужой resource key, объединение аккаунтов, прямые ссылки, unmount в процессе загрузки, отсутствие плагина при restore и ошибку его validate. Проверяйте desktop и Telegram/mobile, русский и английский язык и базовый кабинет без установленных пользовательских модулей. Core предоставляет контракты и журнал, но корректность внешней идемпотентности проверяется с конкретным сервисом.