auto_awesome Elysv0.1.1
Elyschevron_rightДокументация разработчика

Пишем модули для Elys

Команды, вотчеры, инлайн-интерфейсы, конфиг и база данных — от первого «Hello, world» до публикации.

Архитектура

Модуль Elys — это обычный Python-файл с классом-наследником loader.Module. Ядро загружает его, регистрирует команды и обработчики и прокидывает в экземпляр клиент Telegram, базу данных и инлайн-менеджер.

deployed_code

elystl

MTProto-клиент (форк Telethon). Импорт telethon тоже работает — он прозрачно указывает на elystl.

account_tree

loader

Декораторы, конфиг, валидаторы, установка зависимостей и жизненный цикл модулей.

alt_route

dispatcher

Разбирает входящие сообщения, проверяет права и вызывает команды и вотчеры.

widgets

inline

Собственный бот: формы, галереи, списки, inline-запросы и колбэки.

Hello, world

Минимальный модуль с одной командой:

# meta developer: @yourname
# meta banner: https://example.com/banner.png

from elystl.tl.types import Message

from .. import loader, utils


@loader.tds
class HelloMod(loader.Module):
    """Здоровается с миром"""

    strings = {
        "name": "Hello",
        "hello": "👋 <b>Hello, {}!</b>",
    }
    strings_ru = {
        "hello": "👋 <b>Привет, {}!</b>",
    }

    @loader.command(ru_doc="[имя] — поздороваться")
    async def hello(self, message: Message):
        """[name] — say hello"""
        name = utils.get_args_raw(message) or "world"
        await utils.answer(message, self.strings("hello").format(utils.escape_html(name)))
  1. Сохрани файлнапример как hello.py.
  2. Отправь его себе в Telegramи ответь на него командой .lm.
  3. Проверь.hello Elys → «👋 Привет, Elys!». Справка появится в .help Hello.
lightbulb

Имя команды = имя метода. Суффикс cmd отбрасывается: метод pingcmd станет командой .ping.

Мета-заголовки

Комментарии в начале файла сообщают загрузчику, как ставить модуль.

ЗаголовокНазначение
# meta developer: @nickАвтор модуля — отображается при установке
# meta banner: <url>Баннер, показываемый при установке (также атрибут banner_url)
# meta pic: <url>Иконка модуля
# requires: aiohttp pillowpip-зависимости — ставятся автоматически
# packages: ffmpegСистемные пакеты
# scope: elys_min 0.1.0Минимальная версия Elys (понимает и hikka_min / heroku_min)
# scope: inlineМодулю обязателен инлайн-бот
# scope: ffmpegМодулю нужен ffmpeg
# scope: no_mlЗапретить выгрузку модуля файлом через .ml
# scope: disable_onload_docsНе показывать список команд после установки
# packurl: <url>Внешний языковой пак для модуля

Команды

@loader.command(
    alias="w",                       # .w == .weather
    ru_doc="<город> — погода",       # описание для .help на русском
    en_doc="<city> — weather",
)
async def weather(self, message: Message):
    """<city> — weather"""          # описание по умолчанию
    if not (city := utils.get_args_raw(message)):
        reply = await message.get_reply_message()
        city = reply.raw_text if reply else None
    if not city:
        return await utils.answer(message, self.strings("no_city"))

    msg = await utils.answer(message, "⏳")
    data = await utils.run_sync(fetch_weather, city)   # блокирующий код — в поток
    await utils.answer(msg, data)

Аргументы

ФункцияВозвращает
utils.get_args_raw(m)Всё после команды одной строкой
utils.get_args(m)Список аргументов с учётом кавычек
utils.get_args_html(m)Аргументы с HTML-разметкой сообщения
utils.get_args_split_by(m, ",")Аргументы, разделённые своим разделителем
utils.get_args_int(m) / get_args_bool(m)Аргументы, приведённые к int / bool

Ответ

await utils.answer(message, text) редактирует исходящее сообщение или отвечает на входящее; длинный текст сам разобьётся на части. Принимает HTML и reply_markup для инлайн-кнопок. Файлы — utils.answer_file(message, file, caption=…).

warning

Всегда пропускай пользовательский ввод через utils.escape_html() перед вставкой в HTML-ответ.

Строки и переводы

strings — базовые строки (обязателен ключ name). Переводы — словари strings_<lang>: strings_ru, strings_de, strings_ua… Elys выберет язык по .setlang, а при отсутствии ключа вернётся к базовому.

strings = {"name": "Weather", "no_city": "🚫 <b>Specify a city</b>"}
strings_ru = {"no_city": "🚫 <b>Укажи город</b>", "_cls_doc": "Погода в любом городе"}

self.strings("no_city")      # или self.strings["no_city"]

Декоратор @loader.tds делает докстринги переводимыми: описания команд берутся из ru_doc/en_doc в @loader.command или из ключей _cmd_doc_<команда>, описание модуля — из _cls_doc.

Конфиг и валидаторы

Опции попадают в интерактивный .config, хранятся в базе и проверяются валидаторами.

def __init__(self):
    self.config = loader.ModuleConfig(
        loader.ConfigValue(
            "api_key",
            None,
            lambda: "API-ключ сервиса",
            validator=loader.validators.Hidden(),      # скрыт звёздочками
        ),
        loader.ConfigValue(
            "units",
            "metric",
            lambda: "Система единиц",
            validator=loader.validators.Choice(["metric", "imperial"]),
        ),
        loader.ConfigValue(
            "cities",
            ["Moscow"],
            lambda: "Избранные города",
            validator=loader.validators.Series(loader.validators.String(max_len=64)),
        ),
        loader.ConfigValue(
            "timeout",
            10,
            lambda: "Таймаут запроса, сек",
            validator=loader.validators.Integer(minimum=1, maximum=120),
            on_change=self._reconnect,                 # вызовется при изменении
        ),
    )

# чтение / запись
units = self.config["units"]
self.config["timeout"] = 30

Валидаторы

ВалидаторПараметры
Boolean()—
Integer()digits, minimum, maximum
Float()minimum, maximum
String()length, min_len, max_len
Choice(values) / MultiChoice(values)Одно / несколько значений из списка
Series(validator)Список; min_len, max_len, fixed_len
RegExp(regex)flags, description
Link(), TelegramID(), Emoji(), EntityLike()Ссылка, Telegram ID, эмодзи, юзернейм/ID
Hidden(validator)Скрывает значение (токены, ключи)
Union(*validators), NoneType()Любой из валидаторов; разрешить None

База данных

Каждый модуль получает своё пространство имён (по имени класса). Значения должны быть JSON-сериализуемыми.

self.set("counter", self.get("counter", 0) + 1)

# pointer — «живой» список/словарь: изменения сразу сохраняются в базу
self.notes = self.pointer("notes", [])
self.notes.append({"text": "купить хлеб"})

# доступ к чужому пространству (осторожно!)
prefix = self.db.get("elys.main", "command_prefix", ".")

Вотчеры

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

@loader.watcher("in", "only_pm", no_commands=True)
async def autoreply(self, message: Message):
    if self.get("afk"):
        await message.reply("💤 Я сейчас не у телефона")

@loader.watcher(only_groups=True, regex=r"(?i)\belys\b")
async def mention(self, message: Message):
    ...
ГруппаФильтры
Направлениеout, in
Тип чатаonly_pm, only_groups, only_channels, no_pm, no_groups, no_channels
Содержимоеonly_messages, only_media, only_photos, only_videos, only_audios, only_docs, only_stickers, only_inline, only_forwards, only_reply и их no_*-варианты
Командыonly_commands, no_commands
Текстstartswith=, contains=, regex=
Источникfrom_id=, chat_id=

Пользователь может ограничить вотчер командами .watcher и .watcherbl, поэтому не полагайся на то, что он сработает везде.

Инлайн-формы

self.inline.form() отправляет сообщение с кнопками от инлайн-бота. Колбэк получает InlineCall, через который сообщение можно редактировать.

@loader.command()
async def vote(self, message: Message):
    await self.inline.form(
        "🗳 <b>Нравится Elys?</b>",
        message,
        reply_markup=[
            [
                {"text": "👍 Да", "callback": self._voted, "args": (True,), "style": "success"},
                {"text": "👎 Нет", "callback": self._voted, "args": (False,), "style": "danger"},
            ],
            [{"text": "🌐 GitHub", "url": "https://github.com/ZavozDevs/Elys"}],
        ],
        ttl=3600,          # через час кнопки перестанут работать
    )

async def _voted(self, call: InlineCall, liked: bool):
    await call.answer("Спасибо!")                        # всплывашка
    await call.edit("❤️ Ура!" if liked else "😢 Жаль")   # можно передать reply_markup

Ключи кнопок

КлючОписание
textПодпись кнопки (обязательно)
callback + args / kwargsКорутина-обработчик и её аргументы
urlСсылка
input + handlerПопросить пользователя ввести текст
dataСырые callback-данные для @loader.callback_handler
switch_inline_query, switch_inline_query_current_chatПереключение в inline-режим
styleЦвет кнопки: primary, success, danger
emoji_idID премиум-эмодзи для иконки кнопки

Параметры form(): photo, gif, video, file + mime_type, force_me, always_allow, disable_security, ttl, on_unload, silent.

await self.inline.gallery(message, next_handler=self._next_cat, caption="🐈 Котики")
await self.inline.list(message, strings=["Стр. 1", "Стр. 2", "Стр. 3"])

Inline-запросы и колбэки

Inline-хендлер

Срабатывает, когда пользователь пишет @твой_бот запрос. Имя хендлера — первое слово запроса.

@loader.inline_handler(ru_doc="<текст> — сделать текст громким")
async def loud(self, query: InlineQuery):
    """<text> — make text loud"""
    text = query.args or "elys"
    return {
        "title": text.upper(),
        "description": "Отправить капсом",
        "message": f"<b>{utils.escape_html(text.upper())}</b>",
    }

Вернуть можно словарь или список словарей; каждый должен содержать один из ключей message, photo, gif, video или file (+ mime_type).

Callback-хендлер

@loader.callback_handler()
async def raw_cb(self, call: InlineCall):
    if not call.data.startswith("weather/"):
        return
    await call.answer(call.data.split("/", 1)[1])

Фоновые циклы

@loader.loop(interval=3600, autostart=True, wait_before=True)
async def refresh(self):
    self.set("rates", await self._fetch_rates())

# управление вручную
self.refresh.start()
self.refresh.stop()
self.refresh.status   # True, если цикл запущен

stop_clause="ключ_бд" привязывает работу цикла к значению в базе. Внутри цикла можно бросить loader.StopLoop, чтобы остановить его.

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

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

@loader.command()
@loader.group_admin_ban_users
async def kick(self, message: Message):
    ...
ДекораторКто может вызывать
@loader.ownerТолько владельцы (по умолчанию)
@loader.group_ownerСоздатель группы
@loader.group_adminЛюбой админ группы
@loader.group_admin_ban_users, …_delete_messages, …_pin_messages, …_invite_users, …_change_info, …_add_adminsАдмин с конкретным правом
@loader.group_memberЛюбой участник группы
@loader.pmЛюбой собеседник в ЛС
@loader.unrestrictedВсе
@loader.inline_everyoneInline-хендлер доступен всем

Жизненный цикл

async def client_ready(self):
    """Клиент готов, конфиг загружен — здесь основная инициализация"""
    self.me = await self.client.get_me()

async def on_dlmod(self):
    """Только при первой установке через .dlmod / .lm"""

async def on_unload(self):
    """Модуль выгружается или перезагружается — закрой сессии и задачи"""
    await self._session.close()

Подпись client_ready(self, client, db) из Hikka тоже поддерживается. В модуле доступны self.client, self.db, self.inline, self.tg_id, self.lookup("Имя") и self.get_prefix().

Исключения

ИсключениеЭффект (в client_ready)
loader.LoadError("причина")Модуль не загрузится, пользователь увидит причину
loader.SelfUnloadТихо выгрузить модуль
loader.SelfSuspendТихо приостановить: модуль останется, но без команд

Библиотеки

Общий код можно вынести в библиотеку — класс-наследник loader.Library, имя которого оканчивается на Lib. Модули подключают её по ссылке:

async def client_ready(self):
    self.api = await self.import_lib("https://example.com/weather_lib.py")

import_lib работает только внутри client_ready.

Справочник utils

ФункцияЧто делает
answer(m, text, **kw)Ответить / отредактировать сообщение
answer_file(m, file, caption)Отправить файл в ответ
escape_html(text)Экранировать HTML
get_user(m)Отправитель (или автор ответа)
get_chat_id(m)ID чата сообщения
get_link(entity), get_entity_url(entity)Ссылка на пользователя / канал
get_message_link(m)Ссылка на сообщение
smart_split(text, entities)Разбить длинный текст с разметкой
run_sync(func, *args)Выполнить блокирующую функцию в потоке
chunks(list, n)Разбить список на части
rand(n)Случайная строка длины n
format_file_size(bytes)«1.4 MB»
is_url(s), extract_urls(text)Работа со ссылками
has_media(m), mime_type(m)Информация о медиа
censor(obj)Скрыть токены и ключи в объекте

Совместимость

check

Hikka / Heroku

Модули работают как есть: те же loader, utils, декораторы, inline API и формат базы.

check

FTG

Классические модули с методами …cmd и self.strings("key", message).

check

MCUB

Оба стиля: класс ModuleBase и def register(kernel). Тип определяется автоматически.

info

Для новых модулей используй нативный стиль Elys (loader.Module) — так доступны все возможности ядра.

Отладка

  • Используй logging.getLogger(__name__) — записи попадут в .logs.
  • .logs debug -f — подробный лог файлом (может содержать личные данные).
  • .e self.lookup("Hello").get("counter") — быстро посмотреть состояние модуля.
  • После правки файла просто выполни .lm повторно — модуль перезагрузится, вызвав on_unload.

Вклад в ядро

  1. ФоркниZavozDevs/Elys и создай ветку.
  2. Соблюдай стильформатирование black, проверка ruff/flake8.
  3. Пиши осмысленные коммитыв стиле Conventional Commits: fix(loader): …, feat(emoji): ….
  4. Сохраняй совместимостьс базами и модулями Hikka/Heroku. Медиа — только из репозитория ZavozDevs/assets.
  5. Открой Pull Requestс описанием изменений.

Elys распространяется под лицензией GNU AGPLv3.

↑ ↓ выборEnter перейтиEsc закрыть