[Из песочницы] Транспорт-бот Jabber конференций для Telegram

21d7c898537a4c15a58634311ab31a7a.png

Доброго времени суток.

В один прекрасный день, после значительного перерыва, судьба вновь столкнула меня с jabber-конференциями. Правда, среди знакомых jabber уже никто не использует, 2007 год канул в лету, а основным средством общения стал Telegram. Поддержка XMPP на мобильных устройствах оставляла желать лучшего — клиенты на Android хороши каждый в чём-то одном, с iOS и WP всё мягко скажем, не очень. И особенности протокола тоже сказываются на автономности. Поэтому возникла мысль:, а не сделать ли бота, которой будет транслировать сообщения из конференций в чат Telegram?

В качестве инструментов использовались:

  • Python 3.5
  • aiohttp для API Telegram
  • slixmpp для xmpp
  • gunicorn как wsgi сервер
  • nginx как фронтенд и прокси для gunicorn
  • VS Code в качестве IDE


Основные возможности и зависимости


Из готовых реализаций удалось найти только jabbergram, но он позволяет работать только с одним юзером. Ещё есть реализация на Go, с которым опыта работы не было, так что этот вариант не рассматривался и о функционале не могу ничего сказать.

Выбор библиотек обусловлен, в основном, желанием поработать с asyncio.

Изначально разрабатывалась версия с tet-a-tet диалогом для одного пользователя, которая позднее была расширена использованием XMPP Components для групповых чатов, с отдельным xmpp-юзером для каждого участника.

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

Почему так сделано? API ботов весьма ограничивает количество входящих/исходящих запросов за короткое время, и при достаточно интенсивном обмене сообщениями будут возникать ошибки.

Что есть в целом:

  • Отправка/приём текстовых сообщений в общем диалоге
  • Двусторонее редактирование сообщений (XEP-0308)
  • Приватные сообщения
  • Ответ по нику собеседника
  • Файлы, аудио, изображения (загружаются через сторонний сервис)
  • Стикеры (заменяются на emoji)
  • Автостатус при неактивности с последнего сообщения
  • Смена ника в конференции

Тем не менее, есть различия между двумя версиями:

  • «Подсветка» сообщений с ником пользователя не работает в групповых чатах, так как в телеграме невозможно это сделать индивидуально
  • Бот делает групповой чат в телеграмм бесшовным, т.е., если участника забанили в xmpp-конференции, он не может писать сообщения в чат

При разработке удобно использовать виртуальные окружения, так что можно создать одно:

$ python3.5 -m venv venv
$ . venv/bin/activate

Для использования нужно установить из pip aiohttp, slixmpp и ujson. При желании можно добавить gunicorn. С окружением или без, все пакеты есть в PyPI:
$ pip3 install aiohttp slixmpp ujson

В конце поста есть ссылки на bitbucket репозитории с исходниками.

История telegram


Прежде стоит отметить, что готовые фреймворки для API Telegram не использовались по ряду причин:
  • На момент начала работы asyncio поддерживал только aiotg. Сейчас, кажется, все популярные
  • Вебхуки часто реализованы как добавка к лонг пуллу и в любом случае приходится использовать библиотеку для обработки входящих соединений
  • В целом, многие возможности библиотек были просто не нужны
  • Ну или просто NIH

Так что была сделана простенькая обёртка над основными объектами и методами bots api, запросы отправляются с помощью requests, json парсится ujson, потому что быстрее.

Настройка бота осуществляется посредством скрипта-конфига:

config.py
VERSION = "0.1"

TG_WH_URL = "https://yourdomain.tld/path/123456"

TG_TOKEN = "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
TG_CHAT_ID = 12345678

XMPP_JID = "jid@domain.tld"
XMPP_PASS = "yourpassword"
XMPP_MUC = "muc@conference.domain.tld"
XMPP_NICK = "nickname"

DB_FILENAME = "bot.db"
LOG_FILENAME = "bot.log"

ISIDA_NICK = "IsidaBot"  # для фильтрации сообщений с заголовками ссылок от xmpp бота
UPLOADER_URL = "example.com/upload"  # загрузчик файлов

# для групповых чатов нет XMPP_JID/XMPP_PASS/XMPP_NICK и используются дополнительно иные параметры:
# TG_INVITE_URL = "https://telegram.me/joinchat/ABCDefGHblahblah"  # ссылка на групповой чат
# COMPONENT_JID = "tg.xmpp.domain.tld"
# COMPONENT_PASS = "password"
# XMPP_HOST = "xmpp.domain.tld"
# XMPP_PORT = 5347


Представление объектов выглядит примерно так:

mapping.py
class User(object):

    def __str__(self):
        return ''.format(self.id, self.first_name, self.last_name, self.username)

    def __init__(self, obj):
        self.id = obj.get('id')
        self.first_name = obj.get('first_name')
        self.last_name = obj.get('last_name')
        self.username = obj.get('username')

Класс бота для выполнения запросов:

bind.py
class Bot(object):

    def _post(self, method, payload=None):
        r = requests.post(self.__apiUrl + method, payload).text
        return ujson.loads(r)
    ...
    def getMe(self):
        r = self._post('getMe')
        return User(r.get('result')) if r.get('ok') else None
    ...
    @property
    def token(self):
        return self.__token
    ...
    def __init__(self, token):
        self.__token = token
        ...

Все запросы обрабатываются с помощью вебхуков, которые приходят на адрес TG_WH_URL.
RequestHandler.handle () — coroutine для обработки запросов aiohttp.

handler.py
from aiohttp import web
import asyncio

import tgworker as tg  # модуль для работы с bots api
import mucbot as mb  # модуль с процедурами xmpp
import tinyorm as orm  # небольшая обёртка над sqlite3

class RequestHandler(object):
    ...
    async def handle(self, request):
        r = await request.text()

        try:
            ...
            update = tg.Update(ujson.loads(r))

            log.debug("TG Update object: {}".format(ujson.loads(r)))
            ...
        except:
            log.error("Unexpected error: {}".format(sys.exc_info()))
            ...
            raise
        finally:
            return web.Response(status=200)

    def __init__(self, db: orm.TableMapper, mucBot: mb.MUCBot, tgBot: tg.Bot, tgChatId, loop):
        self.__db = db
        self.__tg = tgBot
        self.__mb = mucBot
        self.__chat_id = tgChatId
        self.__loop = loop
        ...

...

loop = asyncio.get_event_loop()
whHandler = RequestHandler(db, mucBot, tgBot, TG_CHAT_ID, loop)

app = web.Application(loop=loop)
app.router.add_route('POST', '/', whHandler.handle)
...

В процессе обработки текстовые сообщения отправляются в конференцию. Либо как приватное сообщение, если это ответ на приватное сообщение или при ответе добавлена команда /pm.

Файлы перед отправкой загружаются на сторонний сервер и в конференцию отправляется ссылка на файл. Скорее всего, для общего использования такой подход не подойдёт и придётся сделать загрузку на Imgur или другой сервис, который предоставляет API. Сейчас же файлы просто отправляются на сервер jTalk. С позволения разработчика, конечно. Но, так как это всё-таки для личного пользования, то адрес вынесен в конфиг.

Стикеры просто заменяются на их emoji-представление.

Опус о xmpp


В своё время для python было две весьма популярных библиотеки — SleekXMPP и xmpppy. Вторая уже устарела и не поддерживается, а асинхронность SleekXMPP реализована потоками. Из библиотек, которые поддерживают работу с asyncio есть aioxmpp и slixmpp.

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

Slixmpp — это SleekXMPP на asyncio, интерфейс там такой же, соответственно, большинство плагинов будут работать. Она используется в консольном jabber-клиенте Poezio.
К тому же, у slixmpp замечательная поддержка, которая помогла решить некоторые проблемы с библиотекой.

Однопользовательская версия использует slixmpp.ClientXMPP в качестве базового класса, когда как многопользовательская — slixmpp.ComponentXMPP

Обработчик событий XMPP выглядит примерно вот так:

mucbot.py
import slixmpp as sx

class MUCBot(sx.ClientXMPP):
# class MUCBot(sx.ComponentXMPP):  # версия для групповых чатов
    ...
    #
    # Event handlers
    #

    def _sessionStart(self, event):
        self.get_roster()
        self.send_presence(ptype='available')
        self.plugin['xep_0045'].joinMUC(self.__mucjid, self.__nick, wait=True)
        # для групповых чатов необходимо подключить всех пользователей
        ...

    #
    # Message handler
    #

    def _message(self, msg: sx.Message):
        log.debug("Got message: {}".format(str(msg).replace('\n', ' ')))
        ...

    #
    # Presence handler
    #

    def _presence(self, presence: sx.Presence):
        log.debug("Got Presence {}".format(str(presence).replace('\n', ' ')))
        ...

    #
    # Initialization
    #

    def __init__(self, db, tgBot, tgChatId, jid, password, mucjid, nick):
        super().__init__(jid, password)

        self.__jid = sx.JID(jid)
        self.__mucjid = sx.JID(mucjid)
        self.__nick = nick

        self.__tg = tgBot
        self.__db = db
        self.__chat_id = tgChatId
        ...
        # настройка плагинов поддержки разных XEP
        self.register_plugin('xep_XXXX')  # Service Discovery
        ...
        # подписка на события xmlstream
        self.add_event_handler("session_start", self._sessionStart)
        self.add_event_handler("message", self._message)
        self.add_event_handler("muc::{}::presence".format(mucjid), self._presence)
        ...

Очевидно, обязательным будет подключить XEP-0045 для MUC, еще полезным будет XEP-0199 для пингов и XEP-0092, чтобы показывать всем какие мы классные свою версию.

Сообщения из xmpp просто отправляются в чат с пользователя (или групповой чат) с TG_CHAT_ID из конфига.

Настройка XMPP-сервера для работы с компонентами


Интересная особенность — это использование компонентов xmpp для динамического создания пользователей. При этом не надо создавать отдельный объект для каждого пользователя и хранить данные для авторизации. Минус в том, что не получится использовать свой основной аккаунт.

Из соображений лёгкости и простоты выбран Prosody в качестве xmpp-сервера.

Описывать конфигурацию не буду, единственное отличие от шаблонна — включение компонента (COMPONENT_JID из конфига бота):

Component "tg.xmpp.domain.tld"
	component_secret = "password"

конфигурация Prosody

В общем-то, это вся настройка xmpp. Остаётся только перезапустить prosody.

Сказ о gunicorn и nginx


Если так совпало, что у вас по счастливой случайности наружу смотрит nginx, стоит добавить директиву в секцию server.
nginx.cfg
location /path/to/123456 {
    error_log  /path/to/www/logs/bot_error.log;
    access_log /path/to/www/logs/bot_access.log;

    alias /path/to/www/bot/public;

    proxy_pass http://unix:/path/to/www/bot/bot.sock:/;
}

Настройку HTTPS описывать, думаю, не стоит, но сертификаты получались через letsencrypt.

Конфигурацию для примера брал из этого комментария. Полный конфиг можно посмотреть здесь, параметры для шифрования подбирались в Mozilla SSL Generator

Вся эта конструкция из… палок работает на VPS с Debian 8.5, так что для systemd написан сервис, который запускает gunicorn:

bot.service
[Unit]
After=network.target

[Service]
PIDFile=/path/to/www/bot/bot.pid
User=service
Group=www-data
WorkingDirectory=/path/to/www/bot
ExecStart=/path/to/venv/bin/gunicorn --pid bot.pid --workers 1 --bind unix:bot.sock -m 007 bot:app --worker-class aiohttp.worker.GunicornWebWorker
ExecReload=/bin/kill -s HUP $MAINPID
ExecStop=/bin/kill -s TERM $MAINPID
PrivateTmp=true

[Install]
WantedBy=multi-user.target


Конечно, не помешает выполнить systemctl daemon-reload и systemctl enable bot.

Ссылки на исходники


  • Однопользовательская версия
  • Версия для групповых чатов

P.S. На премию красивейший код года не претендую. Хотелось, конечно, сделать хорошо, но получилось как всегда.

Комментарии (0)

© Habrahabr.ru