Cluster · разработчикам

Для разработчиков

Модуль Cluster пишется на Python. Команды, обработчики сообщений, кнопки и другие действия объявляются в классе Module. Установленная копия пакета называется экземпляром. У каждого экземпляра свои данные, конфигурация и принятые права.

Начнём с рабочего модуля, затем добавим хранение данных, кнопки и страницу на сайте. Публикация разобрана отдельно в Вебшопе, параметры всех методов в API модуля.

Содержание

Основа модуля

Создай каталог и файл main.py:

ping/
    main.py
    manifest.json

Содержимое main.py:

from __future__ import annotations

from time import perf_counter
from typing import TYPE_CHECKING

if TYPE_CHECKING: from router import Event

class Module:
    @loader.command("ping", access="owner", source="both")
    async def ping(self, ctx: Event) -> None:
        started = perf_counter()
        outgoing = ctx.source == "telegram" and ctx.data.get("out", False)

        if outgoing: await ctx.edit("Cluster is running.")
        else: message = await ctx.reply("Cluster is running.")

        text = f"Cluster is running.\nPing: {(perf_counter() - started) * 1000:.0f} ms"
        if outgoing: await ctx.edit(text)
        elif ctx.source == "bot": await ctx.app.bot.edit_message(ctx.chat_id, message["id"], text)
        else: await ctx.app.client.edit_message(ctx.chat_id, message["id"], text)

    @loader.command("echo", access="owner", source="both")
    async def echo(self, ctx: Event) -> None:
        await ctx.reply(ctx.args or "Usage: echo <text>")

При загрузке доступны два глобальных объекта: app и loader. После создания класса добавляется module, экземпляр этого класса. Импортировать их в рабочем коде не нужно.

loader отмечает методы, которые ядро сможет вызвать. ctx содержит событие: сообщение, команду, нажатие или действие сайта. В этом примере исходящая команда аккаунта редактируется через ctx.edit. Личный бот и входящая команда получают ответ через ctx.reply: чужое сообщение отредактировать нельзя. Задержка измеряется по первому редактированию или отправке ответа, затем результат добавляется в это же сообщение. Это время запроса из модуля, включая обработку ядром.

from __future__ import annotations позволяет оставить Event только внутри TYPE_CHECKING. В среде модуля этот тип находится в router. Обычный запуск файла через python main.py не создаёт окружение Cluster: в нём не будет loader, app и соединения с ядром.

Параметры декоратора:

ПараметрТипЗначение
alias`str \None`Название команды. Без него используется имя метода
accessstrowner, trusted или public
sourcestrtelegram, bot или both

source="both" разрешает команды из Telegram-аккаунта и личного бота. Для аккаунта используется префикс юзербота, исходно !. Личный бот принимает /ping и /ping@username.

В примере доступ выбран явно: owner. Посторонний человек такую команду не запустит. Если параметр не указан, исходное значение trusted: овнеры и доверка с необходимым правом.

Готовые файлы этого примера: main.py и manifest.json. В расширенном Ping дополнительно есть баннер и сведения о юзерботе; для него нужно его собственное описание пакета.

Описание пакета

manifest.json хранит описание версии:

{
    "name": "Ping",
    "description": "Команды ping и echo для проверки аккаунта и личного бота. Доступны только овнерам.",
    "version": "1.0.0",
    "permissions": {
        "telegram.read": {},
        "telegram.send": {},
        "bot.read": {},
        "bot.send": {}
    },
    "dependencies": [],
    "changes": "Проверка ответа и передачи текста через аккаунт и личного бота.",
    "source": true,
    "interface": {
        "commands": [
            {"id": "echo", "alias": "echo", "access": "owner", "source": "both"},
            {"id": "ping", "alias": "ping", "access": "owner", "source": "both"}
        ]
    }
}

Поля name, description, version, permissions, dependencies и changes обязательны. Пустые зависимости всё равно записываются как [].

interface.commands содержит отдельную запись на каждый метод. id совпадает с именем Python-метода, alias с исходным названием команды. Владелец может изменить алиас при установке или в настройках. ID обработчика при этом остаётся прежним.

Ядро сравнивает описание с фактическими декораторами. Отличающееся право доступа, лишняя команда или отсутствующее поле не принимаются.

Порядок списков также учитывается. Загрузчик обходит методы по имени, поэтому в описании выше echo идёт перед ping. Для каждой группы обработчиков соблюдай порядок имён методов. Порядок страниц и элементов должен соответствовать спискам pages в коде.

Описание в архиве и описание, переданное при публикации, должны совпадать по смыслу и интерфейсу. Сам сайт пока не получает значения формы из manifest.json автоматически.

Соответствие декораторов разделам описания:

ДекораторРаздел interfaceДополнительные поля записи
loader.commandcommandsalias, access, source
loader.handlerhandlerssource, event, access
loader.callbackcallbacksfields, access, confirm
loader.actionactionsfields, access, confirm
loader.functionfunctionsfields
loader.signalsignalsfields
loader.tasktasksfields
loader.requestrequestsfields

У каждой записи есть id, имя метода. Поля fields используют тот же формат типов, что конфигуратор. config и pages берутся из одноимённых атрибутов класса. Методы подготовки, остановки и loader.thread отдельными группами в interface не записываются.

Первый запуск

  1. Подготовь ZIP-архив с main.py в корне.
  2. На сайте открой «Каталог модулей» и нажми «Опубликовать свой модуль».
  3. Загрузи архив, заполни описание и выбери четыре права из примера.
  4. В поле описания команд передай объект из interface.
  5. Для первой проверки выключи показ в общем каталоге.
  6. Опубликуй версию, установи её себе и включи экземпляр в «Моих модулях».

Для команд аккаунта нужен подключённый Telegram, для команд бота подключённый личный бот. Сервисный бот не заменяет личного.

При установке прими необходимые права. Можно ограничить чатами только «Избранное» и личный чат с ботом. Команды из других чатов в таком случае не получат доступ.

Проверка:

ИсточникКомандаОжидаемый ответ
«Избранное»!pingCluster is running. и задержка на следующей строке
«Избранное»!echo проверкапроверка
Личный бот/pingCluster is running. и задержка на следующей строке
Личный бот/echo проверкапроверка

Если префикс аккаунта изменён, используй его вместо !. У команды может быть и другой алиас, выбранный при установке.

Класс приложения

app содержит следующие объекты:

ОбъектНазначениеОсновные права
app.clientTelegram-аккаунтtelegram.read, telegram.send
app.botЛичный ботbot.read, bot.send
app.inlineСообщения с кнопкамиbot.send
app.dbДанные экземпляраdb.read, db.write
app.configКонфигурация экземпляраconfig.read
app.uiЗначения страницы на сайтеinterface.read, interface.write
app.filesФайлыfiles.read, files.write
app.webHTTPS и WSSweb.request или web.full
app.tasksРасписаниеtasks.create
app.modulesФункции и события других модулейmodules.call
app.requestsВходящие HTTP-запросыrequests.manage
app.exchangeОбмен между юзерботамиexchange.request
app.logsЖурналlogs.write

Все обращения к этим объектам проходят через ядро. Они предоставляют методы Cluster, перечисленные в Telegram и ботах. Полный состав классов Telethon и aiogram через эти объекты не экспортируется.

app.request(path, **data) позволяет обратиться к маршруту ядра напрямую. Он не расширяет права и не открывает операторские операции. Перечень маршрутов модуля находится в ROUTES.md.

Для документации типов:

from typing import TypeAlias

JSON: TypeAlias = None | bool | int | float | str | list["JSON"] | dict[str, "JSON"]

Значения между модулем и ядром передаются как JSON. Python-объект клиента, открытый файл, генератор или произвольный класс передать нельзя. Для файлов предусмотрены отдельные методы.

Среда модуля

Экземпляр выполняется в отдельной среде gVisor от непривилегированного пользователя. Подключения к Telegram, токены ботов и базы находятся в ядре. Контейнер получает результаты разрешённых запросов.

При запуске пакет копируется в /work/module. Рабочая копия доступна для изменения, исходный пакет подключён только для получения файлов. Изменения в рабочем каталоге и /tmp не сохраняются после пересоздания среды. Постоянные данные записывай через app.db и app.files.

Оперативная память, процессор и число процессов ограничены. Модули, сборки и терминал одного юзербота входят в общую группу ресурсов. Личный предел экземпляра не добавляет ресурсы сверх предела юзербота.

Установка Python-пакетов из PyPI или приложенных wheels выполняется на стадии сборки по requirements. pip install в init её не заменяет: прямая сеть закрыта, рабочие изменения временные. Канал получения пакетов доступен только среде сборки.

Дополнительные файлы импортируются из каталога пакета, например import helpers. Объекты app и loader передаются только в main.py. Во вспомогательные функции передавай нужный объект аргументом. Имена app.py, loader.py и router.py лучше оставить файлам среды, чтобы не смешивать свои импорты с SDK.

Стандартный ввод и исходный канал вывода используются для обмена с ядром. Не вызывай input(), не получай данные из sys.stdin и не меняй внутренние каналы app.

Обычный Python print загрузчик перенаправляет в диагностический вывод. Он не заменяет app.logs.write и не создаёт обычную запись пользовательского журнала. У дочерних процессов вывод нужно направить явно:

import subprocess
from pathlib import Path

binary = Path(__file__).parent / "bin/hello"
result = subprocess.run([str(binary)], capture_output=True, text=True, check=True)
value = result.stdout.strip()

Так результат программы не попадёт в канал протокола ядра. Для исполнения своего бинарного файла нужны совместимая сборка и права файла на запуск. Пример сборки находится в STORE.md.

Запуск и остановка

Module() создаётся без аргументов. Подготовку, которой нужны методы ядра, выполняй в init:

class Module:
    async def init(self) -> None:
        self.value: int = await app.db.get("count", default=0)

    async def stop(self) -> None:
        await app.db.set("count", self.value)

Здесь нужны db.read и db.write. Но сохранять важные изменения только в stop не стоит: при принудительном завершении он может не выполниться. Обычно значение сохраняется сразу после изменения.

Несколько стадий подготовки можно объявить через @loader.init(priority=...). Меньшее число выполняется раньше. При одинаковом приоритете методы идут по имени. Обычный init имеет приоритет 100.

@loader.stop отмечает дополнительные методы остановки. Они вызываются в обратном порядке обнаружения. Для небольшого модуля достаточно обычных init и stop.

Если у модуля есть обработчики, после подготовки начинается получение событий. Одни config и pages не удерживают процесс запущенным. Если нет обработчиков и работа внутри init закончилась, процесс завершится.

app.stop() завершает цикл получения новых событий. Текущий обработчик продолжает выполнение до возврата.

Команды и события

Для обработки сообщения без префикса используется @loader.handler:

from __future__ import annotations

from typing import TYPE_CHECKING

if TYPE_CHECKING: from router import Event

class Module:
    @loader.handler(source="telegram", event="message", access="owner")
    async def incoming(self, ctx: Event) -> None:
        if ctx.text != "cluster?": return
        await ctx.reply("Cluster is running.")

Для этого примера нужны telegram.read и telegram.send. В описании версии добавляется:

{
    "handlers": [
        {"id": "incoming", "source": "telegram", "event": "message", "access": "owner"}
    ]
}

Это содержимое interface, а не полное описание версии.

Параметры handler:

ПараметрЗначение
sourcetelegram, bot или both
eventmessage, edited или config
accessowner, trusted или public

Исходный доступ обычного обработчика сообщений равен public. Если он должен реагировать только на овнеров, укажи это явно.

Для сообщения в ctx доступны:

АтрибутТипСодержимое
textstrПолный текст сообщения
argsstrТекст после названия команды
chat_id`int \None`ID чата
sender_id`int \None`Telegram ID отправителя
id`int \None`ID сообщения
actor`str \None`ID установленного ядром профиля Cluster
sourcestrИсточник события
valuesdictПроверенные поля действия
datadictОстальные данные события

У действия сайта или задачи сообщения может не быть. Перед обращением к чату в общем обработчике учитывай None.

ctx.reply(text) отправляет ответ. ctx.edit(text) меняет сообщение события, если это разрешено. В обычном чате аккаунт не сможет изменить чужое сообщение только потому, что оно пришло в обработчик.

Редактирование сообщения не повторяет команду автоматически. Для него используется отдельный обработчик event="edited".

Права и доступ

Различай право модуля и право человека.

Право модуля разрешает операцию ядра. Например, telegram.send позволяет отправлять сообщения в принятых пределах. Доступ команды определяет, кто может использовать эту возможность через обработчик.

ДоступКто может вызвать
ownerОвнеры юзербота
trustedОвнеры и доверка с правом команды или действия
publicЛюбой подходящий отправитель, в пределах прав модуля

public не отменяет ограничения чатов и не даёт терминал. Но такой обработчик позволяет внешнему человеку использовать выданные модулю возможности. Например, публичная команда с записью в базу может менять данные модуля по запросу постороннего.

Право указывается и в версии, и в выбранных при установке разрешениях. Нет одного из них, операция отклоняется.

{
    "telegram.read": {"chats": [-1001234567890]},
    "telegram.send": {"chats": [-1001234567890]},
    "web.request": {"domains": ["api.example.com"]}
}

{} у конкретного права означает отсутствие дополнительных ограничений. Пустой объект всех прав {} не разрешает ничего. Пустой список чатов запрещает доступ в чаты.

Пользователь может сузить заявленное право. Также действуют общие настройки юзербота и настройки экземпляра. Локальное правило той же операции заменяет общее, но не расширяет выданное модулю разрешение.

telegram.full и bot.full открывают поддержанные общие методы API. Реальная сессия и токен бота в среду не передаются. Если общий запрос не позволяет проверить ограничения, ядро его отклонит.

Весь перечень прав, ограничения и доверка разобраны в PERMISSIONS.md.

Данные

У каждого экземпляра свой раздел базы. В двух экземплярах app.db.get("count") обращается к разным значениям.

from __future__ import annotations

from typing import TYPE_CHECKING

if TYPE_CHECKING: from router import Event

class Module:
    @loader.command("count", access="owner")
    async def count(self, ctx: Event) -> None:
        value: int = await app.db.get("count", default=0)
        value += 1

        await app.db.set("count", value)
        await ctx.reply(str(value))

Права: db.read, db.write, telegram.read, telegram.send. В interface.commands нужен обработчик count с теми же параметрами.

Основные обращения:

value = await app.db.get("name", default=None)
await app.db.set("name", "value")
await app.db.set("temporary", {"ready": True}, ttl=3600)
removed = await app.db.delete("temporary")
page = await app.db.keys(limit=100)

ttl задаётся в секундах. После истечения срока получение возвращает значение по умолчанию. Для обхода ключей передавай page["after"] в следующий запрос, пока он не станет None.

Получение и запись являются отдельными обращениями. Последовательная обработка событий экземпляра не превращает их в общую транзакцию с другими модулями. Например, другой модуль с db.full может менять тот же раздел между обращениями.

В SDK нет открытого объекта Mekansm и метода общей транзакции. Не обещай пользователю атомарность нескольких отдельных запросов.

Данные должны помещаться в квоту юзербота. Для больших вложений используй файлы, а не длинные base64-строки в базе.

Конфигурация

Конфигуратор строится из Module.config. Автор описывает поля, сайт создаёт элементы управления.

class Module:
    config = {
        "enabled": {
            "type": "boolean",
            "label": {"ru": "Включить ответы", "en": "Enable replies"},
            "default": True,
        },
        "step": {
            "type": "integer",
            "label": {"ru": "Шаг счётчика", "en": "Counter step"},
            "min": 1,
            "max": 100,
            "default": 1,
        },
        "token": {
            "type": "secret",
            "label": {"ru": "Ключ внешнего сервиса", "en": "External service key"},
        },
    }

    async def init(self) -> None:
        self.enabled: bool = await app.config.get("enabled", True)

Нужно право config.read. Такой же объект config указывается в interface.config версии.

Для лучшего понимания типов:

ТипЗначениеОграничения
stringСтрокаmin, max длины
secretСкрытая строкаНе помещай настоящий секрет в default
integerЦелое числоmin, max
numberКонечное числоmin, max
booleanПереключательTrue или False
selectОдно значениеСписок choices
listСписокТип items, пределы длины
chatID чатаНенулевое целое число
userTelegram IDПоложительное целое число
fileID файла ClusterФайл выбирается на сайте
jsonДанные JSONДо 32 KiB

items для списка принимает string, integer, number, boolean, chat или user. Произвольную вложенную схему в items указать нельзя.

Общие атрибуты: type, label, default, required, min, max, choices, items, when. Не каждому типу подходят все ограничения. Точные пределы находятся в Модулях.

when={"field": "enabled", "equals": True} показывает поле при выбранном значении другого поля. Сравнение учитывает тип: False, 0 и "0" различаются. Python-код в условиях не выполняется.

Сайт не получает сохранённое значение секрета обратно. Он показывает только наличие. Модуль получает само значение через app.config. Это не делает секрет безопасным для журнала, сообщений или страницы: их содержимое задаёт автор.

Для изменения конфигурации во время работы есть обработчик:

from __future__ import annotations

from typing import TYPE_CHECKING

if TYPE_CHECKING: from router import Event

class Module:
    @loader.handler(event="config")
    async def changed(self, ctx: Event) -> None:
        self.enabled: bool = await app.config.get("enabled", True)

В событии приходит номер изменения, не полный набор значений. Получи новые значения через app.config. Другой вариант для небольшой команды: получать параметр перед каждым действием.

Кнопки

Кнопки отправляет личный бот. Для формы нужны подключённый личный бот и bot.send.

Макет клавиатуры:

buttons = [[
    {"text": "+1", "callback": "increment", "values": {"amount": 1}},
    {"text": "+5", "callback": "increment", "values": {"amount": 5}},
]]

Внешний список содержит ряды, каждый внутренний список кнопки одного ряда.

Типы полей:

ПолеТипЗначение
textstrПодпись
callbackstrИмя метода с @loader.callback
valuesdictПоля обработчика
onceboolОдноразовое нажатие, исходно False

Отправка:

message = await app.inline.form(
    chat,
    "Выбери действие",
    buttons,
    ttl=3600,
    users=None,
    media=None,
)

chat: int, ttl: int, users: list[int] | None. Список users дополнительно ограничивает, кто может нажать кнопку. None не означает овнерку: остаётся проверка доступа обработчика.

Личный бот должен иметь возможность отправить сообщение в выбранный чат. В личной переписке пользователь сначала открывает бота и начинает диалог.

Обработчик:

from __future__ import annotations

from typing import TYPE_CHECKING

if TYPE_CHECKING: from router import Event

class Module:
    @loader.callback(fields={"amount": {"type": "integer", "min": 1, "max": 100, "default": 1}})
    async def increment(self, ctx: Event) -> None:
        count: int = await app.db.get("count", default=0)
        await app.db.set("count", count + ctx.values["amount"])

        await ctx.edit(str(count + ctx.values["amount"]))
        await ctx.answer()

Нужны db.read, db.write и bot.send. Для команд, открывающих форму, добавляются права получения соответствующего источника.

ctx.answer(text="", alert=False) завершает нажатие. alert=True показывает отдельное окно Telegram. Если обработчик завершился без ответа на нажатие, SDK отправит пустой ответ.

ctx.edit внутри обработчика кнопки меняет сообщение формы и сохраняет её клавиатуру. Ядро проверяет привязку к боту, экземпляру, сообщению и чату.

confirm=True у декоратора требует повторного нажатия тем же человеком. Подтверждение обычной кнопки выбирает автор. Пароль профиля и TOTP не нужно собирать собственной кнопкой.

Поля обработчика и параметры access, confirm также входят в interface.callbacks.

Страницы модуля

Страница описывается в Module.pages. Например:

pages = [{
    "id": "main",
    "title": {"ru": "Счётчик", "en": "Counter"},
    "elements": [
        {"id": "count", "type": "metric", "label": "Количество"},
        {"id": "reset", "type": "form", "action": "reset", "label": "Задать значение"},
    ],
}]

Этот список является атрибутом класса Module и повторяется в interface.pages.

app.ui.set("main", {"count": 5}) задаёт данные страницы. Для получения используется app.ui.get("main"). Нужны interface.write и interface.read соответственно.

Виды элементов:

ВидДанные
textТекст
metricЧисло или строка
tableСтроки с заявленными columns
buttonВызов action
formПоля и вызов action
tasksРасписание экземпляра

Для кнопки или формы объяви действие:

from __future__ import annotations

from typing import TYPE_CHECKING

if TYPE_CHECKING: from router import Event

class Module:
    @loader.action(fields={"value": {"type": "integer", "min": 0, "max": 1000, "default": 0}}, confirm=True)
    async def reset(self, ctx: Event) -> int:
        value: int = ctx.values["value"]
        await app.db.set("count", value)
        await app.ui.set("main", {"count": value})
        return value

Поля формы берутся из декоратора действия. Здесь нужны db.write и interface.write. Описание метода входит в interface.actions.

Сайт проверяет сеанс, доступ человека и поля. При confirm=True появится отдельное подтверждение. Результат действия должен быть JSON или None.

Данные страницы не обновляются от записи в базу автоматически. После изменения счётчика отдельно вызови app.ui.set. При запуске восстанови страницу из сохранённых данных.

Произвольный HTML, JavaScript и собственные компоненты React в описание страницы не входят. Сайт строит интерфейс из поддержанных элементов.

Счётчик

В examples/publish/main.py собраны команда, конфигурация, кнопка и страница. Описание версии готово для публикации.

По порядку:

  1. init получает сохранённый счётчик и задаёт данные страницы.
  2. counter получает шаг из конфигурации и отправляет форму.
  3. increment проверяет переданное число, меняет базу и обновляет сообщение.
  4. reset получает значение с сайта, сохраняет его и обновляет страницу.
  5. changed обновляет данные страницы после изменения конфигурации.

Если отправка формы завершается RuntimeError, пример пытается ответить обычным сообщением через источник команды. Это выбранное поведение примера. Для своего модуля можно отдельно обработать отсутствие бота или отказ прав.

Для счётчика нужны девять прав из его описания. Они не выдаются из-за наличия методов в app: пользователь принимает их при установке.

Файлы и вложения

Для загрузки файла из памяти:

file = await app.files.upload("note.txt", b"Hello", ttl=3600)

Нужно files.write. Результат содержит id, имя, размер, контрольную сумму и состояние. Это ID файла Cluster, не путь сервера и не Telegram file_id.

Получение частями:

size = 0
async for part in app.files.chunks(file["id"]): size += len(part)

Нужно files.read. Для больших файлов используй порции, не собирай всё содержимое в память без необходимости.

app.files.list(after=None, limit=100) возвращает файлы текущего раздела. Для чужого раздела своего юзербота требуется files.full. Получение других юзерботов через это право не открывается.

Вложение Telegram сохраняется через app.client.download_media(chat, message, maximum=10485760). Нужны telegram.read и files.write. Отправка через app.client.send_file или app.bot.send_file дополнительно требует files.read и право отправки соответствующего источника.

При выборе файла в конфигураторе ядро открывает доступ к выбранному файлу конкретному экземпляру. Другие файлы из этого выбора ему не становятся доступны.

Подписи методов и виды вложений находятся в TELEGRAM.md, загрузка порциями в SDK.md.

Сеть

Прямая сеть контейнера закрыта. Для внешнего API используется app.web:

import json

class Module:
    async def init(self) -> None:
        result = await app.web.request("https://api.example.com/status")

        if result["status"] != 200: return
        data: dict = json.loads(result["body"])
        await app.db.set("status", data)

Здесь нужны web.request с domains: ["api.example.com"] и db.write. Адрес вымышленный, замени его своим API.

Для JSON-запроса:

result = await app.web.request(
    "https://api.example.com/items",
    method="POST",
    headers={"Content-Type": "application/json"},
    body=json.dumps({"name": "example"}),
)

Поддержаны HTTPS и WSS на порту 443. Перенаправление само не выполняется: следующий адрес проходит отдельную проверку. Домен без поддоменов не разрешает автоматически все его поддомены.

Статус HTTP 400 или 500 возвращается в result["status"]. Он сам по себе не становится исключением. Отдельно обработай ошибку сервиса и неправильный формат ответа.

У экземпляра свой профиль cookies и прокси. Для изменения профиля используется app.web.profile. Даже полный сетевой доступ проходит через ядро и не открывает локальные адреса сервера.

Скачивание в файл, отправка файла и WSS приведены в SDK.md.

Расписание

Для отложенного действия объявляется задача:

from __future__ import annotations

import time
from typing import TYPE_CHECKING

if TYPE_CHECKING: from router import Event

class Module:
    @loader.task(fields={"value": {"type": "integer", "required": True}})
    async def save(self, ctx: Event) -> None:
        await app.db.set("value", ctx.values["value"])

    async def init(self) -> None:
        await app.tasks.create("save", time.time() + 60, {"value": 5})

Нужны tasks.create и db.write. В interface.tasks объяви save и его поля.

at является Unix-временем, а не задержкой в секундах. Для повтора есть interval, для повторных попыток retry. Описание аргументов находится в SDK.md.

Не создавай одну и ту же задачу при каждом запуске, если это не нужно. Сохрани её ID в базе либо используй одинаковый request с совпадающими параметрами. Защита от повтора имеет срок хранения, это не постоянное уникальное ограничение.

У задачи нет исходного чата команды. Если она отправляет сообщение, явно передай адресата в её полях и получи соответствующее право.

Другие модули

Общие Python-объекты между экземплярами не передаются. Для обращения объявляется функция:

from __future__ import annotations

from typing import TYPE_CHECKING

if TYPE_CHECKING: from router import Event

class Module:
    @loader.function(fields={"value": {"type": "integer", "required": True}})
    async def double(self, ctx: Event) -> int:
        return ctx.values["value"] * 2

Описание попадает в interface.functions. Другой модуль вызывает её через app.modules.call. Этот метод сам ждёт выполнения и возвращает результат. Получение состояния через маршрут modules/result уже выполняется внутри SDK, отдельного метода app.modules.result нет. Событие без ожидаемого результата отправляется через app.modules.emit, получатель объявляет @loader.signal.

result: int = await app.modules.call("MODULE_ID", "double", {"value": 3}, timeout=10)

MODULE_ID заменяется ID опубликованного модуля. При истечении ожидания поднимается TimeoutError. Уже начавшееся действие получателя от этого не отменяется.

dependencies версии содержит постоянные ID публикаций. При нескольких экземплярах зависимости пользователь выбирает нужный профиль. Имена Python-класса, отображаемое название и ID публикации не взаимозаменяемы.

Вызывающему нужно modules.call. Для доступа к своим данным получателю нужны собственные права. Полномочия цепочки вызовов также учитываются ядром.

Выключенный или удалённый получатель не обязан отвечать. Обработай недоступность, если от зависимости можно отказаться. Подробности параметров и состояний находятся в SDK.md.

Входящие запросы

Обработчик внешнего HTTP-запроса:

from __future__ import annotations

from typing import TYPE_CHECKING

if TYPE_CHECKING: from router import Event

class Module:
    @loader.request(fields={"text": {"type": "string", "required": True}})
    async def receive(self, ctx: Event) -> bool:
        await app.db.set("incoming", ctx.values["text"])
        return True

Нужны requests.manage для создания адреса и db.write для примера. В interface.requests объявляется receive.

app.requests.create("receive") выдаёт путь и ключ. Внешний клиент отправляет JSON на этот путь с заголовком Authorization: Bearer TOKEN. Сначала возвращается ID задания, результат получается отдельным запросом. Полный протокол находится в SDK.md.

При повторном создании ключ заменяется. Не создавай новый адрес на каждом запуске без необходимости. Не показывай ключ на общей странице и не включай его в журнал.

Такой запрос не получает личность овнера. Возможности определяются правами модуля и ключом входящего запроса.

Обмен юзерботов

Для другого юзербота используется app.exchange. Это отдельная возможность, не разновидность app.modules.call.

Модуль описывает ресурс, формат и направление обмена. Адресат одобряет договор. До одобрения проверка не раскрывает, существует ли запрошенная запись или файл. Для передачи нужен действующий договор и запущенный получатель.

При отзыве новые передачи прекращаются. Договор не позволяет выполнять команды другого юзербота. Права, все поля договора и примеры находятся в EXCHANGE.md.

Ошибки

Ожидаемую ошибку пользователя можно обработать в команде:

from __future__ import annotations

from typing import TYPE_CHECKING

if TYPE_CHECKING: from router import Event

class Module:
    @loader.command("double", access="owner")
    async def double(self, ctx: Event) -> None:
        try: value = int(ctx.args)
        except ValueError:
            await ctx.reply("Usage: double <integer>")
            return

        await ctx.reply(str(value * 2))

Отказ ядра приходит как RuntimeError. В частности, отсутствие права не возвращает None и не заменяется успешным результатом. Коды и формат ошибок находятся в SDK.md и API.md.

Необработанная ошибка или превышение времени отключает экземпляр. Он остаётся отключённым до ручного включения или переустановки.

handler_timeout класса задаёт предел одного обработчика в секундах: исходно 30, допустимо от 1 до 240. Обычные обработчики экземпляра выполняются последовательно. Долгая команда задержит следующие.

Этот предел работает через asyncio.wait_for, а не отдельный таймер процесса. Синхронный код без передачи управления, например бесконечный цикл внутри async def, может заблокировать его срабатывание. Отмена ожидания потока также не останавливает сам поток. Для принудительного завершения такой работы нужно остановить экземпляр на сайте: ядро завершит весь контейнер.

Обычная функция выполняется в потоке. @loader.thread также предназначен для обычной функции, не для async def. Поток остаётся в той же среде с теми же пределами ресурсов.

Очередь не является постоянным хранилищем. Перезапуск ядра может прервать доставку. Не выдавай это поведение за гарантию однократного выполнения внешнего платежа или другого необратимого действия. Используй собственный ID операции и проверку состояния внешнего сервиса.

Для журнала:

await app.logs.write("External service is unavailable", level="warn")

Нужно logs.write. Допустимые уровни: info, warn, error. Сообщения занимают квоту. Не помещай в них токены, пароли и содержимое личных сообщений без необходимости.

Выполнение Python-кода

Модуль может выполнять переданный Python-код, например через meval. Пакет добавляется в requirements и в поле «Пакеты Python» при публикации. meval без номера допустим; meval==2.5 закрепляет версию. Установка происходит при сборке, после чего в main.py доступен обычный from meval import meval.

Для такой команды укажи access="owner". Код берётся из ctx.args; пустой аргумент обработай до вызова библиотеки. Собственный разбор префикса не нужен: префикс юзербота и алиас команды применяет диспетчер.

В окружение выполнения можно передать ctx, app, self и module. Если назовёшь событие event, это всё равно будет Event Cluster. Текст находится в ctx.text, аргументы в ctx.args, для ответа используются ctx.reply и ctx.edit. Атрибутов Telethon, например raw_text, у этого события нет. Поля перечислены в описании Event.

app.client тоже является SDK Cluster. Сообщения и пользователи возвращаются словарями; настоящий объект Telethon и сессия остаются внутри ядра. Для доступных низкоуровневых методов есть app.client.invoke, ему нужен telegram.full. Само вычисление Python не требует этого права. Доступ к базе, файлам, сети и другим модулям задаётся отдельно. Исполняемый код получает возможности своего экземпляра и не расширяет их.

Показывай 0, False, пустую строку и пустые коллекции как результат. Отсутствие результата проверяется через result is None, а не if not result. Ошибки пользовательского кода обрабатывай внутри команды, если они не должны отключать экземпляр.

ctx.reply, ctx.edit и формы с кнопками передают обычный текст. Теги <code> сами по себе не включают HTML. В методах аккаунта предел текста составляет 4096 байт UTF-8; большой результат можно отправить файлом через app.files и соответствующий метод Telegram или бота. Для этого потребуются файловые права. Предел времени обработчика и ограничения синхронного кода описаны в ошибках.

Обновления

Считай каждую опубликованную версию неизменяемой. Исправление кода, описания, прав или интерфейса оформляется новой версией под прежним ID модуля.

Сохраняй имена обработчиков, если их используют кнопки, расписание или другие модули. Переименование Python-метода меняет ID обработчика. Смена алиаса команды сама по себе ID не меняет.

Для изменения формата данных используй собственный номер схемы в базе. Проверь прежний формат до изменения. После успешного переноса запиши новую версию схемы. Частично выполненный перенос должен допускать повтор или завершаться понятной ошибкой.

Например, прежняя версия хранила значение в counter, новая использует count:

class Module:
    async def migrate(self) -> None:
        version: int = await app.db.get("schema_version", default=0)

        if version == 0:
            value: int = await app.db.get("counter", default=0)
            await app.db.set("count", value)
            await app.db.set("schema_version", 1)
        elif version != 1: raise RuntimeError("Unsupported data version")

    async def init(self) -> None: await self.migrate()

Права: db.read, db.write. Старый ключ пока сохраняется. Если выполнение прервётся до записи schema_version, перенос повторит ту же запись. Для удаления старых данных или изменения нескольких связанных сущностей понадобится собственный порядок восстановления. Пример показывает перенос одного значения.

Автоматического отката данных при ошибке обновления нет. Резервная копия позволяет пользователю восстановить прежние данные, но не заменяет логику переноса.

Проверь обновление на копии старого состояния, сохранение конфигурации и повторный запуск. Новые права могут потребовать отдельного согласия пользователя. Не рассчитывай, что они уже приняты сразу после выхода новой версии.

Порядок выпуска, архивирование и тестеры находятся в STORE.md.

Публикация

Для первого модуля достаточно ZIP-архива, описания и выбранных прав. Команды загрузки файла из Telegram нет.

Подробный порядок публикации включает:

Перед публикацией пройди проверку модуля. Полные подписи методов собраны в SDK.md, описание декораторов, конфигуратора и страниц в MODULES.md.