Транспорт-бот Jabber конференций для Telegram



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

В один прекрасный день, после значительного перерыва, судьба вновь столкнула меня с 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 '<User id={} first_name="{}" last_name="{}" username={}>'.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. На премию красивейший код года не претендую. Хотелось, конечно, сделать хорошо, но получилось как всегда.
P.P.S. Разработка версии для групповых чатов заброшена, ввиду отсутствия желания, времени и ряда проблем с API Telegram.
Поделиться публикацией

Похожие публикации

AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

Комментарии 13

    0
    Эх, начал читать, обрадовался, а потом понял что не в ту сторону)

    Хотя наверное все равно попробую, т.к. с jabber клиентами под iphone все действительно очень-очень плохо.
      0

      Если хотите наоборот — можно попробовать spectrum + libpuurple-telegram.

      • НЛО прилетело и опубликовало эту надпись здесь
      0
      Очень сложный стэк вы намудрили. Не будут этим пользоваться как и vk4xmpp. Если бы вы создали решение на базе модуля Prosody, которые к слову пишутся на LUA, то это еще было бы конфигурабельно. А так слишком много точек отказа будет.
        0
        А что сложного в vk4xmpp? У него всё ещё есть свои пользователи, несмотря на постепенное отмирание jabber.
          0
          Идея транспортов вообще слишком сложна как таковая. Не для IT-шников, а для технически неграмотных пользователей. Сложность в том, что раньше было просто: подключаешься к серверу со своими логином и паролем, затем общаешься. Сейчас нужно какое-то шаманство. Опять же, шаманство с их точки зрения. Вы скажете: «да раз плюнуть, зашёл в обзор сервисов, нашёл транспорт, нажал зарегистрироваться». Но беда в том, что для того, чтобы это сделать, во-первых, нужно знание, во-вторых, существующий Jabber ID. А когда люди качают программу типа Psi+ с мыслью «во, сейчас я загружу программулину для переписки через вконтактик», они остановятся уже после того, как увидят приветственное окно создания аккаунта и, быть может, попробуют написать там vk.com со своими логином и паролем :)
            0
            Согласен насчет сложности подключения для рядового пользователя. На J2ME когда-то был Talkonaut и там транспорты подключались прозрачно. Хорошо бы, чтобы такое было везде, но это уже зависит от клиента.
              0
              Мне больше нравится идея (чисто теоретическая) сделать «транспорт», который работал бы по по обычному C2S-протоколу, т.е принимал соединения от клиентов на 5222 порту. С другой стороны, я вряд-ли стал бы таким сервисом пользоваться, равно как и транспортом — не горю желание включать в свою переписку третью сторону (хотя, конечно, ничто не мешает поднять транспорт у себя, кроме собственной лени, легко объяснимой занятостью другими вещами).
        0
        Правильно ли я понимаю, что webhook лучше long polling?
        Если да, то подскажите, пожалуйста, чем?
          0

          Если коротко, то обрабатывать вебхуки просто удобнее, чем получать обновления через getUpdates.
          Но для webhook все-таки нужна какая-никакая предварительная настройка.
          Для разработки long polling наверное удобнее, хотя для тестирования вебхуков можно ngrok попробовать, например, или ssh туннель.

            0
            Ну я думаю, что удобство зависит от платформы.
            На Java, например, мне кажется, нет ничего проще, чем в фоновом потоке дёргать getUpdates в цикле.
            А есть ли разница в скорости получения обновлений под большой нагрузкой?
              0

              Что стоит понимать под большой нагрузкой?
              Официальные данные говорят, что лимит запросов у ботов весьма маленький. В таких условиях сложно говорить о "высоких" нагрузках.
              Правда, официальные боты или боты вроде яндекса наверняка избавлены от таких проблем.

          +2
          На мой взгляд, вы зря говорите о Jabber, как о чём-то умершем.
          Не следует забывать, что это один из последних вариантов мессаджинга, за вычетом Tox, где можно как-то худо-бедно сохранять анонимность и приватность, в особенности, при использовании собственного сервера.

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое