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

Модули

Пошаговое руководство находится в DEVELOPMENT.md: первый пакет, команды, данные, кнопки и страницы. Здесь приведены подписи декораторов, форматы и пределы. Публикация через сайт и API описана в STORE.md, проверка перед выпуском в MODULE_TESTING.md.

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

from __future__ import annotations
from typing import TYPE_CHECKING

if TYPE_CHECKING: from router import Event

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

    @loader.command("count", source="both")
    async def count_command(self, ctx: Event) -> None:
        await ctx.reply(str(self.count))

    async def stop(self) -> None: pass

Исходник находится в main.py, класс называется Module. При загрузке в глобальную область передаются app и loader. После создания Module() туда же добавляется module, экземпляр этого класса. В init он уже доступен, в конструкторе и коде верхнего уровня ещё нет.

app содержит методы ядра, loader содержит декораторы обработчиков. Импортировать их в модуле не нужно. Event находится в router среды модуля. Импорт внутри TYPE_CHECKING нужен для подсказок типов и не выполняется при запуске.

Каждый экземпляр запускается в отдельном контейнере. Описание interface в пакете должно совпадать с декораторами, config и pages класса. Ядро проверяет совпадение при запуске. Добавить скрытый обработчик, заменить owner на public или подменить описание при сборке не получится: описание связано с неизменяемой версией вебшопа.

Запуск

Подписи декораторов:

loader.init(func=None, *, priority=100)
loader.stop(func)
loader.thread(func)

priority: int задаёт порядок запуска, меньшее число выполняется раньше. При одинаковом числе методы идут по имени. Обычный init без декоратора имеет приоритет 100. Каждый метод запуска вызывается один раз.

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

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

Обычный метод выполняется в потоке, async def в цикле событий. @loader.thread также переносит обычную функцию в поток; для async def он не подходит. Поток остаётся внутри контейнера. Превышение времени ожидания не останавливает уже работающий поток. Блокирующий код внутри async def может задержать и срабатывание таймера. handler_timeout не является жёстким пределом времени процесса; для принудительного завершения останови экземпляр на сайте.

Команда службы run ждёт завершения или остановки модуля. Необязательный --seconds ограничивает время. При остановке юзербота, отзыве подключения или потере ядра управляющий процесс завершает контейнер.

Команды

loader.command(alias=None, *, access="trusted", source="telegram")

В Telegram используется префикс юзербота, исходно !. Личный бот принимает и /command, и /command@username. Команду, обращённую к другому боту, ядро пропускает.

from __future__ import annotations
from typing import TYPE_CHECKING

if TYPE_CHECKING: from router import Event

class Module:
    @loader.command("echo", source="both")
    async def echo(self, ctx: Event) -> None:
        await ctx.reply(ctx.args or "Текст не указан")

ID обработчика равен имени метода и не меняется со сменой алиаса. Алиас содержит от 1 до 32 латинских или русских букв, цифры, подчёркивание и дефис. Регистр не учитывается. Имена exec и load заняты ядром.

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

События и Event

loader.handler(func=None, *, source="telegram", event="message", access="public")

source и access имеют те же значения, что у команды. event принимает message, edited или config: новое сообщение, изменение сообщения или изменение конфигурации. У обычного события исходный доступ public; чтение соответствующего источника всё равно требует принятых прав модуля.

Атрибуты Event:

АтрибутТип и значение
appОбъект методов ядра
dataСловарь исходных данных события
text, argsstr: текст и строка после имени команды
valuesdict: проверенные поля действия, задачи или вызова
chat_id, id`intNone`: чат и сообщение, если они есть у события
sender_id`intNone`: Telegram ID вызывающего, если он известен
actor`strNone`: ID подтверждённого профиля Cluster
source, group, handlerstr: источник, группа и ID обработчика
ticketstr: выданная ядром привязка текущего события
answeredbool: отправлен ли ответ на нажатие

Методы:

await ctx.reply(text, **kwargs)
await ctx.edit(text)
await ctx.answer(text="", alert=False)

text: str, alert: bool. reply отвечает через тот же источник: Telegram или личного бота. У Telegram это ответ на исходное сообщение. edit изменяет сообщение события. Для них нужны чат, соответствующее сообщение и права отправки. У действия сайта или задачи чата может не быть. answer доступен только обработчику нажатия.

actor=None не означает овнерку. Telegram ID может иметь доверку без регистрации, но совпадение строки с ID профиля владельца не делает человека овнером.

В событии config передаётся номер версии, а не новые значения. Значения, включая скрытые поля, получаются отдельно через app.config с правом config.read.

Права вызывающего

owner допускает овнеров, trusted также допускает доверку с правом обработчика, public допускает внешнего человека. Для команды доверке нужен command.execute либо command.ID. Локальный список доверки заменяет общий, в том числе когда он явно пустой.

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

Ядро проверяет человека при принятии события, доставке и дальнейших вызовах. Событие связано с экземпляром и его подключением. Подмена actor, чужое событие или старое подключение отклоняются. Пока обработчик выполняется, отсутствие поля события в следующем запросе не отменяет проверку вызывающего.

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

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

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

Кнопки

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

buttons = [[{"text": "+", "callback": "add", "values": {"amount": 1}}]]
await app.inline.form(chat, "Счётчик", buttons, ttl=3600, users=None, media=None)

chat: int, text: str, buttons: list[list[dict]]. Результат содержит данные отправленного сообщения личного бота. Для формы нужны подключённый бот и bot.send; для вложения также files.read.

Поля кнопки:

ПолеТип и значение
textstr, подпись до 64 символов
callbackstr, имя метода с @loader.callback
valuesdict, поля обработчика; исходно {}
oncebool, запрет повторного запуска; исходно False

До 16 рядов, до 8 кнопок в ряду. Поля одной кнопки занимают не больше 16 KiB JSON. ttl: int от 1 до 604800 секунд, исходно 3600. users: list[int] | None ограничивает нажатия указанными Telegram ID, до 100 значений. media имеет вид {"file": "FILE_ID", "type": "photo"}; виды вложений перечислены в TELEGRAM.md.

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, "default": 1}})
    async def add(self, ctx: Event) -> None:
        count = await app.db.get("count", default=0) + ctx.values["amount"]
        await app.db.set("count", count)
        await ctx.edit(str(count))
        await ctx.answer()

    @loader.command("counter")
    async def counter(self, ctx: Event) -> None:
        buttons = [[{"text": "+", "callback": "add", "values": {"amount": 1}}]]
        await app.inline.form(ctx.chat_id, "Счётчик", buttons)

@loader.callback(func=None, *, fields=None, access="trusted", confirm=False) описывает обработчик. fields использует те же типы полей, что конфигуратор. При confirm=True требуется повторное нажатие того же человека в течение 20 секунд.

В callback_data записывается случайное значение ядра, связанное с экземпляром, версией, ботом, чатом и сообщением. Копирование в другое сообщение не запускает обработчик. Обычная строка module/slot:action такой привязки не создаёт.

ctx.edit сохраняет клавиатуру ядра. ctx.answer(text, alert=False) отвечает на текущее подтверждённое нажатие. Если обработчик завершился без ответа, SDK отправляет пустой ответ. Недоступное нажатие обрабатывает ядро без запуска модуля. Формы и подтверждения входят в квоту юзербота, истёкшие записи очищаются.

Поддержаны обычные и личные сообщения бота внутри групп. receiver задаёт получателя личной формы, replace=True заменяет вид исходного сообщения для нажавшего кнопку. Список users ограничивает нажатия и сам по себе не скрывает сообщение. Все виды кнопок, дополнительные параметры, изменение и удаление формы описаны в BUILDERS.md. Нажатия без связанных чата и сообщения не принимаются. Подтверждения обычных действий выбирает автор; управление овнерами, аккаунтом и профилем остаётся в отдельных действиях ядра с паролем и TOTP.

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

Макет:

class Module:
    config = {
        "enabled": {"type": "boolean", "default": True},
        "count": {"type": "integer", "min": 1, "max": 20, "default": 5},
        "mode": {"type": "select", "choices": ["short", "full"], "default": "short"},
        "token": {"type": "secret", "label": {"ru": "Ключ", "en": "Key"}},
        "file": {"type": "file", "when": {"field": "enabled", "equals": True}},
    }

config: dict[str, dict], до 64 полей. Ключ поля является его ID. У поля есть type, необязательные label, default, required, min, max, choices, items, when.

typeЗначение
string, secretstr, до 4096 байт UTF-8
integerint, модуль числа до 2**53
number`intfloat`, конечное число
booleanbool
chatНенулевой int, ID чата
userПоложительный int, Telegram ID
selectОдно из choices, тип тоже должен совпасть
listlist, до 1000 элементов типа items
filestr, ID готового доступного файла
jsonДанные JSON до 32 KiB

label принимает строку или переводы {"ru": "...", "en": "..."}. required исходно False; необязательное поле может содержать None. Для строки min и max задают длину в символах, для списка число элементов, для чисел диапазон.

choices содержит от 1 до 100 строк, целых чисел или логических значений. False, 0 и "0" различаются. items принимает string, integer, number, boolean, chat, user; исходно string.

when={"field": "enabled", "equals": True} показывает поле при совпадении значения и типа. Ссылка должна вести на существующее поле, циклы запрещены. Скрытое условием поле без значения не требует заполнения; переданное значение всё равно проверяется. Python и JavaScript в условии не выполняются.

Секрет не записывается в исходник значением по умолчанию. Для его сохранения нужен ключ шифрования службы. Сайт получает только {"set": true} или {"set": false}. Оставить сохранённый секрет и очистить его можно отдельно. Сам модуль получает значение через app.config.get(key, default=None) или app.config.all() с правом config.read.

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

Сохранение содержит revision: если настройки уже изменились, старый запрос получает конфликт. Частичное изменение сохраняет остальные значения. Обязательные поля проверяются при сохранении, незаполненная форма остаётся доступна для просмотра.

Страницы

Макет страницы и действия:

from __future__ import annotations
from typing import TYPE_CHECKING

if TYPE_CHECKING: from router import Event

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

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

    async def init(self) -> None:
        await app.ui.set("main", {"count": await app.db.get("count", default=0)})

pages: list[dict], до 16 страниц. Страница содержит id: str, title: str | dict[str, str], elements: list[dict]. До 64 элементов на страницу.

Виды элементов: text, metric, table, button, form, tasks. Каждый содержит id, type, необязательный label. У button и form есть action, ID обработчика @loader.action. У table есть columns, от 1 до 16 уникальных имён столбцов. tasks показывает расписание экземпляра.

@loader.action(func=None, *, fields=None, access="trusted", confirm=False) принимает Event; результатом могут быть данные JSON или None. app.ui.set(page, data) публикует значения по ID элементов с правом interface.write. app.ui.get(page) получает их с interface.read. Данные страницы до 32 KiB. Текст до 4096 байт UTF-8, показатель принимает число или строку. Таблица содержит до 200 строк, только заявленные столбцы и простые значения.

Сайт собирает страницу из описания и значений. HTML, JavaScript и произвольные виды элементов не принимаются; текст не выполняется. Переводы описания выбираются по языку сайта.

Действие проходит проверку сеанса сайта, CSRF, доступа обработчика и полей. При confirm=True ядро выдаёт одноразовое подтверждение на минуту, связанное с сеансом и теми же аргументами. Повтор с тем же request не запускает действие заново, пока результат хранится.

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

Очередь и ошибки

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

До 128 ожидающих событий на экземпляр и до 256 на юзербот. Событие и результат занимают до 32 KiB каждый и действуют до пяти минут. На юзербот хранится до 1024 записей. Очередь находится в памяти: перезапуск ядра не гарантирует повторной доставки и не повторяет действие с неизвестным результатом.

Необработанная ошибка или превышение времени завершает модуль, экземпляр получает состояние ошибки (failed). Ядро снимает обработчики. Если автор перехватил ошибку, дальнейшее поведение определяет его код.

Пример

examples/tools/main.py содержит команду счётчика, кнопку увеличения и форму сброса. manifest.json описывает тот же интерфейс и права.

scripts/probe_modules.py запускает пример в gVisor, передаёт команду и нажатие, выполняет подтверждённое действие через HTTP, получает страницу и проверяет отключение ошибочного обработчика. Telegram и Bot API отвечают тестовыми данными.

Остальные методы: SDK.md. Telegram: TELEGRAM.md. Публикация и сборка: STORE.md. Обмен юзерботов: EXCHANGE.md.