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



Доброго часу доби.

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

В якості інструментів використовувалися:

Основні можливості і залежності
З готових реалізацій вдалося знайти тільки 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®
...
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®)

log.debug("TG Update object: {}".format(ujson.loads®))
...
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"


<a href=«bitbucket.org/snippets/gudvinr/yjypz>конфігурація 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 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.