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")alias: str | None: имя команды; без него используется имя метода.access: str:owner,trustedилиpublic.source: str:telegram,botилиboth.- Аргумент обработчика:
ctx: Event. Возвращаемое значение: данные JSON либоNone.
В 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, args | str: текст и строка после имени команды | |
values | dict: проверенные поля действия, задачи или вызова | |
chat_id, id | `int | None`: чат и сообщение, если они есть у события |
sender_id | `int | None`: Telegram ID вызывающего, если он известен |
actor | `str | None`: ID подтверждённого профиля Cluster |
source, group, handler | str: источник, группа и ID обработчика | |
ticket | str: выданная ядром привязка текущего события | |
answered | bool: отправлен ли ответ на нажатие |
Методы:
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.
Поля кнопки:
| Поле | Тип и значение |
|---|---|
text | str, подпись до 64 символов |
callback | str, имя метода с @loader.callback |
values | dict, поля обработчика; исходно {} |
once | bool, запрет повторного запуска; исходно 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, secret | str, до 4096 байт UTF-8 | |
integer | int, модуль числа до 2**53 | |
number | `int | float`, конечное число |
boolean | bool | |
chat | Ненулевой int, ID чата | |
user | Положительный int, Telegram ID | |
select | Одно из choices, тип тоже должен совпасть | |
list | list, до 1000 элементов типа items | |
file | str, 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.