Архитектура
Модуль Elys — это обычный Python-файл с классом-наследником loader.Module. Ядро загружает его, регистрирует команды и обработчики и прокидывает в экземпляр клиент Telegram, базу данных и инлайн-менеджер.
elystl
MTProto-клиент (форк Telethon). Импорт telethon тоже работает — он прозрачно указывает на elystl.
loader
Декораторы, конфиг, валидаторы, установка зависимостей и жизненный цикл модулей.
dispatcher
Разбирает входящие сообщения, проверяет права и вызывает команды и вотчеры.
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)))
- Сохрани файлнапример как
hello.py. - Отправь его себе в Telegramи ответь на него командой
.lm. - Проверь
.hello Elys→ «👋 Привет, Elys!». Справка появится в.help Hello.
Имя команды = имя метода. Суффикс cmd отбрасывается: метод pingcmd станет командой .ping.
Мета-заголовки
Комментарии в начале файла сообщают загрузчику, как ставить модуль.
| Заголовок | Назначение |
|---|---|
# meta developer: @nick | Автор модуля — отображается при установке |
# meta banner: <url> | Баннер, показываемый при установке (также атрибут banner_url) |
# meta pic: <url> | Иконка модуля |
# requires: aiohttp pillow | pip-зависимости — ставятся автоматически |
# 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=…).
Всегда пропускай пользовательский ввод через 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_id | ID премиум-эмодзи для иконки кнопки |
Параметры 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_everyone | Inline-хендлер доступен всем |
Жизненный цикл
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) | Скрыть токены и ключи в объекте |
Совместимость
Hikka / Heroku
Модули работают как есть: те же loader, utils, декораторы, inline API и формат базы.
FTG
Классические модули с методами …cmd и self.strings("key", message).
MCUB
Оба стиля: класс ModuleBase и def register(kernel). Тип определяется автоматически.
Для новых модулей используй нативный стиль Elys (loader.Module) — так доступны все возможности ядра.
Отладка
- Используй
logging.getLogger(__name__)— записи попадут в.logs. .logs debug -f— подробный лог файлом (может содержать личные данные)..e self.lookup("Hello").get("counter")— быстро посмотреть состояние модуля.- После правки файла просто выполни
.lmповторно — модуль перезагрузится, вызвавon_unload.
Вклад в ядро
- ФоркниZavozDevs/Elys и создай ветку.
- Соблюдай стильформатирование black, проверка ruff/flake8.
- Пиши осмысленные коммитыв стиле Conventional Commits:
fix(loader): …,feat(emoji): …. - Сохраняй совместимостьс базами и модулями Hikka/Heroku. Медиа — только из репозитория ZavozDevs/assets.
- Открой Pull Requestс описанием изменений.
Elys распространяется под лицензией GNU AGPLv3.