Доброго времени суток.
В один прекрасный день, после значительного перерыва, судьба вновь столкнула меня с 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, потому что быстрее.
Настройка бота осуществляется посредством скрипта-конфига:
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
Представление объектов выглядит примерно так:
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')
Класс бота для выполнения запросов:
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.
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 выглядит примерно вот так:
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"
В общем-то, это вся настройка xmpp. Остаётся только перезапустить prosody.
Сказ о gunicorn и nginx
Если так совпало, что у вас по счастливой случайности наружу смотрит nginx, стоит добавить директиву в секцию server.
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
Вся эта конструкция из… палок работает на
[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. На премию красивейший код года не претендую. Хотелось, конечно, сделать хорошо, но получилось как всегда.
Автор: gudvinr