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

Модули Cluster

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

Итак, по порядку: напишем модуль, подготовим описание и опубликуем его в маркете. В качестве примера возьмём две команды. Первая проверяет, работает ли юзербот, вторая возвращает отправленный текст.

Пакет модуля

В минимальном варианте нужны два файла:

ping/
    main.py
    manifest.json

В main.py находится класс Module. В manifest.json сохраняется описание версии: название, возможности, права, зависимости, изменения и интерфейс. Дополнительные файлы Python можно положить рядом с main.py.

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

Один модуль можно установить несколько раз. У каждого экземпляра свои настройки, данные и ограничения. Название экземпляра, например main, выбирается при установке.

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

Для лучшего понимания в примерах указаны типы аргументов и возвращаемых значений.

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>")

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

@loader.command регистрирует команду. Первый аргумент задаёт название, access определяет доступ, source выбирает источник.

Уровни доступа:

Источники:

Для пользовательского аккаунта команда вызывается как !ping, если префикс не изменён. Для личного бота используется /ping. Префикс и названия команд настраиваются на сайте. При совпадении названий с другим модулем конфликт нужно устранить при установке.

Доступ человека и права модуля проверяются отдельно. access="owner" не выдаёт модулю разрешение отправлять сообщения. Нужное право должно быть заявлено автором и принято пользователем.

Событие команды

Аргумент ctx содержит сведения о вызове. Основные поля:

ctx.reply отвечает в контексте полученной команды. Ядро проверяет источник, адресата, права и действующее подключение перед отправкой. Поля события не служат способом выдать себе овнерку.

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

Числовой ID чата передавай как получен, например из ctx.chat_id. После перезапуска ядро само получает недостающие служебные данные из доступных диалогов. Это относится и к прямым запросам API с числовыми адресатами. Права модуля продолжают действовать. Форматы аргументов, различия между обычными и прямыми вызовами, ограничения текста и ошибки находятся в документации Telegram.

Путь команды выглядит так:

Сообщение Telegram или личного бота
    ↓
Выбор команды и экземпляра
    ↓
Проверка человека, чата, подписки и прав
    ↓
Вызов обработчика в среде модуля
    ↓
Запрос модуля к ядру
    ↓
Повторная проверка прав и отправка ответа

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

Описание версии

Для нашего примера 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 обозначает конкретный выпуск. changes описывает изменения этой версии. dependencies перечисляет зависимости от других модулей, в нашем примере их нет.

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

source=true разрешает показывать исходники при сравнении версий. Значение false скрывает это сравнение, но не шифрует пакет и не защищает код от получения человеком, у которого есть доступ к нему.

Права и ограничения

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

Например, так автор ограничивает отправку одним чатом:

{
    "telegram.send": {"chats": [123456789]}
}

В свой модуль подставь нужный ресурс. Число в примере вымышленное.

Для чтения своего раздела базы используется db.read, для записи db.write. Для сетевых запросов нужно web.request, для файлов отдельные права чтения и записи. Запрашивай те права, которыми модуль действительно пользуется. Пользователь видит их перед установкой и обновлением.

Даже полное разрешение на Telegram не передаёт модулю файл сессии. Служебное хранилище остаётся у ядра.

Данные и настройки

Данные экземпляра доступны через app.db. Например, счётчик вызовов:

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

Для этого примера нужны db.read, db.write, telegram.read и telegram.send. Команду также нужно добавить в interface.commands. Фрагмент показывает работу с базой, целиком заменять им только main.py из первого примера нельзя без изменения описания.

Пользовательские настройки объявляются в Module.config. Для поля задаются тип, подпись, начальное значение и ограничения. Сайт строит форму из этого описания. Строки, числа, переключатели, выбор и списки не требуют от пользователя писать JSON вручную. Для секретов есть отдельный тип поля; сохранённое значение сайт обратно не получает.

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

Кнопки и страницы

Кнопка Telegram создаётся через app.inline.form, её обработчик отмечается @loader.callback. Действие страницы сайта объявляется через @loader.action. Страница описывает элементы, а не произвольный JavaScript: текст, показатели, таблицы, кнопки и формы.

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

Кнопки Telegram привязаны к конкретному экземпляру, боту, чату и сообщению. Скопированное значение кнопки не даёт права вызвать её из другого контекста.

Счётчик с настройкой и кнопкой

Теперь пример, в котором используются база, конфигурация, кнопка Telegram и страница сайта. Это отдельный модуль, со своим описанием версии.

В main.py:

from __future__ import annotations

from typing import TYPE_CHECKING

if TYPE_CHECKING: from router import Event

class Module:
    config = {"step": {
        "type": "integer",
        "label": {"ru": "Шаг", "en": "Step"},
        "min": 1,
        "max": 100,
        "default": 1,
    }}
    pages = [{
        "id": "main",
        "title": {"ru": "Счётчик", "en": "Counter"},
        "elements": [
            {"id": "count", "type": "metric", "label": {"ru": "Значение", "en": "Value"}},
            {"id": "reset", "type": "form", "action": "reset", "label": {"ru": "Задать значение", "en": "Set value"}},
        ],
    }]

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

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

    @loader.command("counter", source="both")
    async def counter(self, ctx: Event) -> None:
        count = await self.publish()
        step: int = await app.config.get("step", 1)
        buttons = [[{"text": "+", "callback": "increment", "values": {"amount": step}}]]

        try: await app.inline.form(ctx.chat_id, str(count), buttons)
        except RuntimeError: await ctx.reply(str(count))

    @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"])
        count = await self.publish()

        await ctx.edit(str(count))
        await ctx.answer()

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

    @loader.handler(event="config")
    async def changed(self, ctx: Event) -> None: await self.publish()

step задаёт величину увеличения. Поле ограничено числами от 1 до 100. При запуске init получает сохранённый счётчик и обновляет показатель на странице.

Команда counter отправляет кнопку. Обработчик increment получает её параметры через ctx.values, сохраняет новое значение, меняет текст сообщения и закрывает ожидание нажатия через ctx.answer.

Действие reset вызывается формой сайта. confirm=True требует отдельного подтверждения перед изменением. Обработчик changed получает событие изменения конфигурации.

Для этого модуля в manifest.json:

{
    "name": "Счётчик",
    "description": "Команда, кнопка и страница с общим счётчиком.",
    "version": "1.0.0",
    "permissions": {
        "telegram.read": {},
        "telegram.send": {},
        "bot.read": {},
        "bot.send": {},
        "db.read": {},
        "db.write": {},
        "config.read": {},
        "interface.read": {},
        "interface.write": {}
    },
    "dependencies": [],
    "changes": "Счётчик, кнопка увеличения и форма сброса.",
    "source": true,
    "interface": {
        "commands": [
            {
                "id": "counter",
                "alias": "counter",
                "access": "trusted",
                "source": "both"
            }
        ],
        "handlers": [
            {
                "id": "changed",
                "source": "telegram",
                "event": "config",
                "access": "public"
            }
        ],
        "callbacks": [
            {
                "id": "increment",
                "fields": {
                    "amount": {
                        "type": "integer",
                        "min": 1,
                        "max": 100,
                        "default": 1
                    }
                },
                "access": "trusted",
                "confirm": false
            }
        ],
        "actions": [
            {
                "id": "reset",
                "fields": {
                    "value": {
                        "type": "integer",
                        "min": 0,
                        "max": 1000,
                        "default": 0
                    }
                },
                "access": "trusted",
                "confirm": true
            }
        ],
        "config": {
            "step": {
                "type": "integer",
                "label": {
                    "ru": "Шаг",
                    "en": "Step"
                },
                "min": 1,
                "max": 100,
                "default": 1
            }
        },
        "pages": [
            {
                "id": "main",
                "title": {
                    "ru": "Счётчик",
                    "en": "Counter"
                },
                "elements": [
                    {
                        "id": "count",
                        "type": "metric",
                        "label": {
                            "ru": "Значение",
                            "en": "Value"
                        }
                    },
                    {
                        "id": "reset",
                        "type": "form",
                        "action": "reset",
                        "label": {
                            "ru": "Задать значение",
                            "en": "Set value"
                        }
                    }
                ]
            }
        ]
    }
}

В форму публикации снова переносится только вложенный объект interface. Остальные поля заполняются в своих разделах. Для проверки нужны права из этого описания; разрешений из первого примера недостаточно.

На сайте настрой шаг, в Telegram вызови !counter или /counter, нажми кнопку увеличения. На странице модуля появится значение, а форма позволит задать его вручную. Сохрани данные, перезапусти экземпляр и проверь, что счётчик остался прежним.

Зависимости

Есть два разных вида зависимостей: модули Cluster и пакеты Python.

Зависимость от другого модуля указывается в dependencies. Для обращения к его функциям нужны соответствующие права. Если установлено несколько экземпляров нужного модуля, пользователь выбирает, с каким связывать вызовы.

Пакеты Python перечисляются в requirements, например "requirements": ["meval==2.5"]. При сборке pip получает их из PyPI вместе с зависимостями. В форме публикации для этого есть поле «Пакеты Python». Папку wheels можно не создавать.

Версия необязательна: "requirements": ["meval"] тоже подходит. Без закреплённой версии следующая сборка может получить другой совместимый выпуск.

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

Прямая сеть в среде сборки закрыта. Запросы pip к официальным адресам PyPI передаются через ядро; права модуля на сеть для установки не нужны. Сторонние индексы и произвольные URL не принимаются. Скачанные файлы временные, готовые зависимости входят в квоту юзербота. Конфликт версий или нехватка места останавливает сборку.

Исходные пакеты собираются внутри контейнера. Для двоичных расширений нужны подходящие Python, архитектура, компилятор и системные библиотеки. Совместимость с Python на компьютере автора сама по себе ничего не гарантирует. Подробности описаны в документации по публикации.

Не помещай своё виртуальное окружение в архив. Если для подготовки нужна сборочная программа, она описывается полем build. Через нынешнюю форму сайта это поле не передаётся, для такого пакета используется API публикации.

Проверка пакета

Начальный пример из этой статьи доступен отдельными файлами: main.py и manifest.json. Это простой текстовый Ping. Расширенный Ping с баннером использует своё описание и дополнительные права.

Сначала проверь синтаксис:

python3 -m py_compile main.py
python3 -m json.tool manifest.json

Эти команды не проверяют права, соответствие обработчиков описанию и работу Telegram. Их проверяют при реальном запуске экземпляра.

Собери архив из каталога модуля:

python3 -m zipfile -c ping.zip main.py manifest.json
python3 -m zipfile -l ping.zip

В корне архива должны быть main.py и manifest.json. Не добавляй .git, кеши, пароли, свои базы и Telegram-сессии. Переходы через .., абсолютные пути, символические ссылки и специальные файлы ядро не принимает.

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

Публикация в маркете

Открой сайт Cluster, затем «Каталог модулей» и форму публикации. Можно загрузить архив, указать HTTPS-ссылку или выбрать GitHub.

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

  1. Для первого выпуска нажми «Опубликовать модуль».
  2. Введи название и описание из name и description.
  3. Укажи номер версии и изменения: version и changes.
  4. Перенеси зависимости от модулей и пакетов Python. В примере оба списка пустые.
  5. Открой «Описание команд, полей и страниц», нажми «Вставить JSON» и вставь только объект interface, без остальных полей описания. Нажми «Применить значение».
  6. В блоке прав отметь telegram.read, telegram.send, bot.read и bot.send.
  7. Включи показ исходников, если хочешь открыть сравнение кода между версиями.
  8. Для первой проверки выключи «Показывать модуль в общем каталоге».
  9. Выбери архив и нажми «Опубликовать версию модуля».

Для нашего примера в поле интерфейса вставляется:

{
    "commands": [
        {"id": "echo", "alias": "echo", "access": "owner", "source": "both"},
        {"id": "ping", "alias": "ping", "access": "owner", "source": "both"}
    ]
}

После отправки появится сообщение «Версия опубликована» с постоянным ID публикации. Кнопка «Открыть страницу модуля» ведёт к публикации. Следующие версии добавляются через её управление, без ручного ввода ID. Новая форма «Опубликовать модуль» создаёт самостоятельную публикацию.

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

Установка и проверка в Telegram

В карточке или на странице модуля нажми «Установить». Выбери выпуск, прими права, укажи экземпляр main и при необходимости измени конфликтующие названия команд.

После установки открой «Мои модули» и запусти экземпляр. Установка и запуск выполняются отдельно.

Для проверки:

  1. Отправь !ping в «Избранное» подключённого аккаунта.
  2. Отправь /ping личному боту.
  3. Проверь !echo текст и /echo текст.
  4. Убедись, что посторонний пользователь не может вызвать команды с доступом owner.
  5. Ограничь отправку тестовым чатом и проверь отказ за его пределами.
  6. Перезапусти экземпляр и повтори команды.

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

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

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

В «Исходниках и сравнении» можно выбрать версию, открыть файл или сравнить два выпуска. Для первого выпуска можно сравнить с пустым пакетом. Добавленные и удалённые строки выделены разными цветами, рядом показаны номера строк. Отдельно сравниваются описание, права, зависимости и интерфейс. Обычным пользователям доступны только открытые исходники, администратор может проверить и закрытый пакет перед выдачей галочки.

Файлы листаются страницами по 500 записей. Текстовый предпросмотр ограничен 128 КБ на файл, сравнение одного файла до 2000 строк и 192 КБ. Двоичные и слишком большие файлы отмечены отдельно. При сокращении текста появляется предупреждение. Сравнение не запускает модуль и не заменяет проверку его работы.

В разделе «Оформление» вкладки «Управление» автор может поставить аватарку и баннер. Сайт принимает PNG, JPEG и WebP до 12 МБ и переводит в PNG. После обработки аватарка должна укладываться в 512 × 512 и 256 КБ, баннер в 3200 × 1800 и 4 МБ. Ядро проверяет изображение и убирает метаданные. Картинки занимают место в хранилище автора; замена и удаление освобождают старое место. Оформление хранится отдельно и не требует новой версии пакета.

Отзывы и жалоба находятся во вкладке «Отзывы». Действия автора находятся в «Управлении».

Управление своими публикациями

В «Каталоге модулей» открой «Мои публикации». Здесь видны твои публичные и непубличные модули, а также публикации с ограничениями модератора. Кнопка с шестерёнкой на карточке и кнопка «Управление публикацией» на странице модуля открывают одни и те же настройки.

Удаление публикации не удаляет уже установленные у пользователей экземпляры. Их пакеты сохраняются и продолжают занимать хранилище автора, пока существует хотя бы один экземпляр этого модуля. После удаления последнего экземпляра ядро освобождает пакеты при обслуживании. Активная сборка также откладывает очистку. Картинки публикации и списки тестеров удаляются отдельно; ранее загруженный исходный ZIP остаётся обычным файлом автора, пока он сам его не удалит. Минимальная запись занятого ID сохраняется, чтобы удалённую публикацию нельзя было восстановить повторной отправкой старого номера.

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

Тестеры и общий каталог

Во вкладке «Управление» нажми «Добавить тестера», найди профиль по логину или имени и выбери его. Тестер входит в собственный профиль и устанавливает модуль на свой юзербот. Доступ отзывается отдельной кнопкой рядом с его именем.

Для выхода в общий каталог открой «Управление», выбери «Всем пользователям каталога» и сохрани. Новая версия для смены видимости не нужна.

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

Обновление

Изменение кода, прав, зависимостей или описания выпущенной версии оформляется новой версией. Название и описание карточки редактируются отдельно. Для выпуска 1.0.1:

  1. Измени код и согласуй с ним interface.
  2. Обнови version и changes.
  3. Проверь права и зависимости.
  4. Собери новый архив.
  5. В «Управлении» своей публикации нажми «Добавить версию». ID и прежние поля подставятся автоматически; проверь номер, изменения и интерфейс.
  6. Опубликуй и установи новую версию в проверочный экземпляр.

Для GitHub указываются репозиторий и нужная ветка, метка или коммит. Ядро закрепляет пакет за конкретным коммитом. Последующие изменения ветки не меняют уже опубликованную версию.

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

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

Дополнительный пример

В исходниках «Заметок» собраны команда для аккаунта и личного бота, сохранение данных, настройка заголовка и страница редактирования. Описание пакета можно сверить с обработчиками.

Команда !note текст или /note текст сохраняет заметку, команда без текста показывает её. На странице есть форма сохранения и удаление с подтверждением. Максимальная длина заметки составляет 800 символов. Команда и действия страницы доступны только овнерам.

Кнопки, файлы и зависимости

В составном примере два модуля работают вместе: один отправляет форму с кнопкой, создаёт и проверяет файлы, обращается по HTTPS и ведёт расписание; второй считает SHA256 и принимает событие. Там находятся исходники, описания пакетов, нужные права и порядок проверки в отдельной среде.

Для функции используется app.modules.call: он ждёт результат. app.modules.emit только ставит событие в очередь и не подтверждает обработку. Для обоих нужны modules.call и разрешённый ID зависимости. Если экземпляров несколько, нужно выбрать нужный. Ошибка недоступной зависимости и TimeoutError ожидания обрабатываются отдельно.

Готовые временные файлы удаляй в finally. При неудачной загрузке через app.files.upload SDK тоже пытается удалить частичный файл. Если доступ к ядру или право уже потеряны, очистку завершит ядро по служебному сроку хранения.

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

Перед выпуском

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

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

Автор в каталоге

На карточке модуля и его странице отображается автор со ссылкой на профиль. Обычная галочка или особая отметка основателя рядом с именем относятся к профилю автора. Отметка проверки самой версии выдаётся отдельно. Эти сведения берутся из профиля Cluster: указать себе галочку через описание пакета нельзя.