Pull to refresh

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

Reading time 7 min
Views 22K


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

В один прекрасный день, после значительного перерыва, судьба вновь столкнула меня с 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.
Tags:
Hubs:
+16
Comments 13
Comments Comments 13

Articles