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:
Все файлы должны присутствовать в подписанном 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 предоставляет контракты и журнал, но корректность внешней идемпотентности проверяется с конкретным сервисом.