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

В один прекрасный день, после значительного перерыва, судьба вновь столкнула меня с 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. На премию красивейший код года не претендую. Хотелось, конечно, сделать хорошо, но получилось как всегда.
Поделиться с друзьями
-->

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


  1. capgelka
    18.07.2016 18:02

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

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


    1. gudvinr
      18.07.2016 18:37

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


      1. xvitaly
        18.07.2016 20:24

        Проект telegram-purple скорее мёртв, чем жив. Его авторы до сих пор не смогли реализовать поддержку супергрупп, поэтому с MUC, можно сказать, что он вообще не работает.


  1. PerlPower
    18.07.2016 18:41

    Очень сложный стэк вы намудрили. Не будут этим пользоваться как и vk4xmpp. Если бы вы создали решение на базе модуля Prosody, которые к слову пишутся на LUA, то это еще было бы конфигурабельно. А так слишком много точек отказа будет.


    1. mrDoctorWho
      19.07.2016 09:13

      А что сложного в vk4xmpp? У него всё ещё есть свои пользователи, несмотря на постепенное отмирание jabber.


  1. fRoStBiT
    18.07.2016 20:24

    Правильно ли я понимаю, что webhook лучше long polling?
    Если да, то подскажите, пожалуйста, чем?


    1. gudvinr
      18.07.2016 20:40

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


      1. fRoStBiT
        18.07.2016 20:44

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


        1. gudvinr
          18.07.2016 21:22

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


  1. ksenobayt
    19.07.2016 07:59
    +2

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