Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

experimental Matrix & Mattermost backends #23

Open
wants to merge 1 commit into
base: python3
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 30 additions & 13 deletions pipobot/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from pipobot.lib.loader import BotModuleLoader
from pipobot.translation import setup_i18n
from pipobot.bot_jabber import BotJabber, XMPPException
from pipobot.bot_mattermost import BotMattermost, MattermostException
from pipobot.bot_test import TestBot

LOGGER = logging.getLogger('pipobot.manager')
Expand Down Expand Up @@ -175,7 +176,7 @@ def _daemonize(self, fd):
getattr(sys, desc).close()
setattr(sys, desc, null)

def _jabber_bot(self, rooms, modules):
def _bot(self, rooms, modules):
signal.signal(signal.SIGINT, self._signal_handler)
signal.signal(signal.SIGTERM, self._signal_handler)
signal.signal(signal.SIGQUIT, self._signal_handler)
Expand All @@ -189,19 +190,35 @@ def _jabber_bot(self, rooms, modules):
bots = []

for room in rooms:
try:
bot = BotJabber(room.login, room.passwd, room.resource,
room.chan, room.nick, modules[room],
self._db_session, self._config.force_ipv4,
room.address, room.port)
except XMPPException as exc:
LOGGER.error("Unable to join room '%s': %s", room.chan,
exc)
continue

LOGGER.info("joining room %s on %s", room.chan, room.protocol)
if room.protocol == 'xmpp':
try:
bot = BotJabber(room.login, room.passwd, room.resource,
room.chan, room.nick, modules[room],
self._db_session, self._config.force_ipv4,
room.address, room.port)
except XMPPException as exc:
LOGGER.error("Unable to join room '%s': %s", room.chan, exc)
continue
elif room.protocol == 'mattermost':
try:
bot = BotMattermost(login=room.login, passwd=room.passwd, modules=modules[room],
session=self._db_session, address=room.address, default_team=room.default_team,
default_channel=room.default_channel)
except MattermostException as exc:
LOGGER.error("Unable to join mattermost '%s': %s", room.address, exc)
continue
elif room.protocol == 'matrix':
try:
# Avoid importing matrix_client lib if not necessary
from pipobot.bot_matrix import BotMatrix # isort:skip
bot = BotMatrix(login=room.login, passwd=room.passwd, chan=room.chan, modules=modules[room],
session=self._db_session, address=room.address)
except Exception as exc:
LOGGER.error("Unable to join matrix '%s': %s", room.chan, exc)
continue
bots.append(bot)


while self.is_running:
signal.pause()

Expand Down Expand Up @@ -294,7 +311,7 @@ def run(self):
if e :
_abort("Unable to load all modules")

self._jabber_bot(rooms, m)
self._bot(rooms, m)

LOGGER.debug("Exiting…")
logging.shutdown()
Expand Down
2 changes: 1 addition & 1 deletion pipobot/bot_jabber.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ def say(self, msg, priv=None, in_reply_to=None):
"""The method to call to make the bot sending messages"""
# If the bot has not been disabled
if not self.mute:
if type(msg) is str or type(msg) is str:
if type(msg) is str:
self.forge(msg, priv=priv, in_reply_to=in_reply_to).send()
elif type(msg) is list:
for line in msg:
Expand Down
107 changes: 107 additions & 0 deletions pipobot/bot_matrix.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# !/usr/bin/python
"""This file contains the class 'BotMatrix' which is a bot for Matrix Chan"""

import logging
import threading
import time

from matrix_client.client import MatrixClient

from pipobot.bot import PipoBot

logger = logging.getLogger('pipobot.bot_matrix')


class BotMatrix(PipoBot):
"""The implementation of a bot for Matrix Chan"""

def __init__(self, login, passwd, chan, modules, session, name='pipobot', address="https://matrix.org"):
logger.info("Connecting to %s", address)
self.client = MatrixClient(address)
logger.debug("login in")
token = self.client.login_with_password(username=login, password=passwd)
if token:
logger.debug("logged in")
else:
logger.error("login failed")
self.room = self.client.join_room(chan)
self.room.add_listener(self.on_message)
logger.debug("connected to %s", self.room)
if name:
logger.debug("set name to %s", name)
self.client.get_user(self.client.user_id).set_display_name(name)
self.name = name

super(BotMatrix, self).__init__(name, login, chan, modules, session)

logger.debug("start listener thread")
self.client.start_listener_thread()
self.say(_("Hi there"))
logger.info("init done")

def on_message(self, room, event):
logger.debug("new event")
if event['type'] == 'm.room.message':
logger.debug("event is a message")
self.message_handler(event)
elif event['type'] == 'm.room.member':
logger.debug("event is a presence")
self.presence_handler(event)

def message_handler(self, event):
"""Method called when the bot receives a message"""
# We ignore messages in some cases :
# - the bot is muted
# - the message is empty
if self.mute or event["content"]["body"] == "":
return

thread = threading.Thread(target=self.answer, args=(event,))
thread.start()

def answer(self, mess):
logger.debug('handling message')
result = self.module_answer(mess)
if type(result) is list:
for to_send in result:
self.say(to_send)
else:
self.say(result)
logger.debug('handled message')

def kill(self):
"""Method used to kill the bot"""

# The bot says goodbye
self.say(_("I’ve been asked to leave you"))
# The bot leaves the room
self.client.logout()
self.stop_modules()
logger.info('killed')

def say(self, msg, priv=None, in_reply_to=None):
"""The method to call to make the bot sending messages"""
# If the bot has not been disabled
logger.debug('say %s', msg)
if not self.mute:
if type(msg) is str:
self.room.send_text(msg)
elif type(msg) is list:

for line in msg:
time.sleep(0.3)
self.room.send_text(line)
elif type(msg) is dict:
if "users" in msg:
pass
else:
if "xhtml" in mess:
mess_xhtml = "<p>%s</p>" % mess["xhtml"]
self.room.send_html(mess_xhtml, msg["text"])
else:
self.room.send_text(msg["text"])

def presence_handler(self, mess):
"""Method called when the bot receives a presence message.
Used to record users in the room, as well as their jid and roles"""
pass
113 changes: 113 additions & 0 deletions pipobot/bot_mattermost.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# !/usr/bin/python
"""This file contains the class 'BotMattermost' which is a bot for Mattermost API"""

import logging
import threading
import time
from json import loads, dumps
from websocket import create_connection
import requests

from pipobot.bot import PipoBot

logger = logging.getLogger('pipobot.bot_mattermost')


class MattermostException(Exception):
""" For errors due to Mattermost (conflict, connection/authentification failed, …) """
pass


class BotMattermost(PipoBot):
"""The implementation of a bot for a Mattermost instance"""

def __init__(self, login, passwd, modules, session, address, default_team, default_channel):
address += '/api/v4'
self.address = address
auth = requests.post('https://%s/users/login' % address, json={'login_id': login, 'password': passwd})

if auth.status_code != 200:
logger.error(_("Unable to connect !"))
raise MattermostException(_("Unable to connect !"))

self.headers = {'Authorization': 'Bearer %s' % auth.headers['Token']}
self.user_id = requests.get('https://%s/users/me' % address, headers=self.headers).json()['id']
team_url = 'https://%s/teams/name/%s' % (address, default_team)
self.default_team_id = requests.get(team_url, headers=self.headers).json()['id']
channel_url = 'https://%s/teams/%s/channels/name/%s' % (address, self.default_team_id, default_channel)
self.default_channel_id = requests.get(channel_url, headers=self.headers).json()['id']

challenge = dumps({"seq": 1, "action": "authentication_challenge", "data": {'token': auth.headers['Token']}})
logger.debug('creating WS')
self.ws = create_connection('wss://%s/websocket' % address)
logger.debug('Sending challenge')
self.ws.send(challenge)

if not self.ws.connected:
logger.error(_("Unable to authenticate websocket !"))
raise MattermostException(_("Unable to authenticate websocket !"))

super(BotMattermost, self).__init__('...', login, default_channel, modules, session)

self.thread = threading.Thread(name='mattermost_' + default_channel, target=self.process)
self.thread.start()
self.say(_("Hi there"))

def process(self):
self.run = True
while self.run:
msg = loads(self.ws.recv())
self.message_handler(msg)


def message_handler(self, msg):
"""Method called when the bot receives a message"""
if self.mute or 'event' not in msg or msg['event'] != 'posted':
return

thread = threading.Thread(target=self.answer, args=(msg,))
thread.start()

def answer(self, mess):
post = loads(mess['data']['post'])
message = {'body': post['message'], 'from': DummySender(mess['data']['sender_name']), 'type': 'chat'}
result = self.module_answer(message)
kwargs = {'root_id': post['parent_id'], 'channel_id': post['channel_id']}
if type(result) is list:
for to_send in result:
self.say(to_send, **kwargs)
elif type(result) is dict:
to_send = result['text'] if 'text' in result else result['xhtml']
if 'monospace' in result and result['monospace']:
to_send = '`%s`' % to_send
self.say(to_send, **kwargs)
else:
self.say(result, **kwargs)

def kill(self):
"""Method used to kill the bot"""

self.say(_("I’ve been asked to leave you"))
self.run = False
self.ws.close()
self.stop_modules()

def say(self, msg, channel_id=None, root_id=""):
"""The method to call to make the bot sending messages"""
# If the bot has not been disabled
if not self.mute:
if channel_id is None:
channel_id = self.default_channel_id
create_at = int(time.time() * 1000)
r = requests.post('https://%s/posts' % self.address, headers=self.headers, json={
'channel_id': channel_id,
'message': msg,
'root_id': root_id
}).json()
if 'status_code' in r:
logger.error(_('error in sent message %s:\nresult is: %s' % (msg, r)))


class DummySender(object):
def __init__(self, sender):
self.resource = sender
18 changes: 14 additions & 4 deletions pipobot/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,8 +183,13 @@ def _load_modules(conf_modules) :
conf_file)

for conf_room in conf_rooms:
kwargs = {}
for param in ['chan', 'login', 'passwd', 'resource', 'nick']:
protocol = conf_room.get('protocol', 'xmpp')
kwargs = {'protocol': protocol}
required_params = {'xmpp': ['resource', 'nick'],
'mattermost': ['address', 'default_channel', 'default_team'],
'matrix': ['address', 'chan'],
}
for param in ['login', 'passwd'] + required_params[protocol]:
value = conf_room.get(param, "")
if not value or not isinstance(value, str):
if "chan" in kwargs:
Expand Down Expand Up @@ -236,9 +241,11 @@ def _load_modules(conf_modules) :


class Room(object):
__slots__ = ('chan', 'login', 'passwd', 'resource', 'nick', 'modules', 'address', 'port')
__slots__ = ('chan', 'login', 'passwd', 'resource', 'nick', 'modules', 'address', 'port', 'protocol',
'default_team', 'default_channel')

def __init__(self, chan, login, passwd, resource, nick, modules, address=None, port=None):
def __init__(self, login, passwd, modules, resource=None, nick=None, chan=None, address=None, port=None,
default_team='', default_channel='', protocol='xmpp', ):
self.chan = chan
self.login = login
self.passwd = passwd
Expand All @@ -247,6 +254,9 @@ def __init__(self, chan, login, passwd, resource, nick, modules, address=None, p
self.modules = modules
self.address = address
self.port = port
self.protocol = protocol
self.default_team = default_team
self.default_channel = default_channel


def get_configuration():
Expand Down
17 changes: 12 additions & 5 deletions pipobot/lib/modules.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,16 +55,23 @@ def __init__(self, bot, desc):

self.prefixs.extend(base_prefixs)

def parse_mess(self, mess):
""" find message body, sender and type of a message """
if 'origin_server_ts' in mess: # Matrix
logger.debug('parsing matrix message')
return mess['content']['body'], mess['sender'], mess['type']
logger.debug('parsing not matrix message')
return mess['body'].lstrip(), mess['from'].resource, mess['type']

def do_answer(self, mess):
""" With an xmpp message `mess`, checking if this module is concerned
by it, and if so get the result of the module and make the bot
say it """

msg_body = mess["body"].lstrip()
sender = mess["from"].resource
msg_body, sender, msg_type = self.parse_mess(mess)

#The bot does not answer to itself (important to avoid some loops !)
if sender == self.bot.name:
if self.bot.name in sender:
return

#Check if the message is related to this module
Expand All @@ -76,15 +83,15 @@ def do_answer(self, mess):
if isinstance(self, SyncModule):
# Separates command/args and get answer from module
command, args = SyncModule.parse(msg_body, self.prefixs)
send = self._answer(sender, args, pm=(mess["type"] == "chat"))
send = self._answer(sender, args, pm=(msg_type == "chat"))
elif isinstance(self, ListenModule):
# In a Listen module the name of the command is not specified
# so nothing to parse
send = self.answer(sender, msg_body)
elif isinstance(self, MultiSyncModule):
# Separates command/args and get answer from module
command, args = SyncModule.parse(msg_body, self.prefixs)
send = self._answer(sender, command, args, pm=(mess["type"] == "chat"))
send = self._answer(sender, command, args, pm=(msg_type == "chat"))
else:
# A not specified module type !
return
Expand Down