From ce57a9c93209e1e34007d0321cfdace59ec597d7 Mon Sep 17 00:00:00 2001 From: nicobo Date: Fri, 22 May 2020 07:29:52 +0200 Subject: [PATCH] + Jabber implementation using slixmpp --- .gitignore | 1 + README.md | 33 ++++- nicobot/jabber.py | 333 ++++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 3 + 4 files changed, 363 insertions(+), 7 deletions(-) create mode 100755 nicobot/jabber.py diff --git a/.gitignore b/.gitignore index 7f9f09a..27f60f3 100644 --- a/.gitignore +++ b/.gitignore @@ -131,3 +131,4 @@ dmypy.json # All local-only / private files **/local* **/priv* +**/.omemo diff --git a/README.md b/README.md index 92b4166..4b12bbc 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,18 @@ See also sample configurations in the `test/` directory. Please first review [YAML syntax](https://yaml.org/spec/1.1/#id857168) if you don't know about YAML. + +## Using the Jabber/XMPP backend + +By using `--backend jabber` you can make the bot chat with XMPP (a.k.a. Jabber) users. + +### Jabber-specific options + +- `--username` and `--password` are the JabberID (e.g. *myusername@myserver.im*) and password of the bot's account, used to send and read messages. If either parameter is missing, will try to read `jid` and `password` from `~/.xtalk` file. +- `--recipient` is the JabberID of the person to send the message to + + + ## Using the Signal backend By using `--backend signal` you can make the bot chat with Signal users. @@ -230,13 +242,6 @@ Sample command line to run the bot with Signal : ## Resources -### Python libraries - -- [xmpppy](https://github.com/xmpppy/xmpppy) : this library is very easy to use but it does allow easy access to thread or timestamp, and no OMEMO... -- [slixmpp](https://lab.louiz.org/poezio/slixmpp) : seems like a cool library too and pretends to require minimal dependencies ; however the quick start example does not work OOTB... It supports OMEMO so it's probably going to be to winner. -- [github.com/horazont/aioxmpp](https://github.com/horazont/aioxmpp) : officially referenced library from xmpp.org, seems the most complete but misses practical introduction and [does not provide OMEMO OOTB](https://github.com/horazont/aioxmpp/issues/338). - - ### IBM Cloud - [Language Translator service](https://cloud.ibm.com/catalog/services/language-translator) @@ -246,3 +251,17 @@ Sample command line to run the bot with Signal : - [Signal home](https://signal.org/) - [signal-cli man page](https://github.com/AsamK/signal-cli/blob/master/man/signal-cli.1.adoc) + +### Jabber + +- Official XMPP libraries : https://xmpp.org/software/libraries.html +- OMEMO compatible clients : https://omemo.top/ +- [OMEMO official Python library](https://github.com/omemo/python-omemo) : looks very immature +- *Gaijim*, a Windows/MacOS/Linux XMPP client with OMEMO support : [gajim.org](https://gajim.org/) | [dev.gajim.org/gajim](https://dev.gajim.org/gajim) +- *Conversations*, an Android XMPP client with OMEMO support and paid hosting : https://conversations.im + +Python libraries : + +- [xmpppy](https://github.com/xmpppy/xmpppy) : this library is very easy to use but it does allow easy access to thread or timestamp, and no OMEMO... +- [github.com/horazont/aioxmpp](https://github.com/horazont/aioxmpp) : officially referenced library from xmpp.org, seems the most complete but misses practical introduction and [does not provide OMEMO OOTB](https://github.com/horazont/aioxmpp/issues/338). +- [slixmpp](https://lab.louiz.org/poezio/slixmpp) : seems like a cool library too and pretends to require minimal dependencies ; plus it [supports OMEMO](https://lab.louiz.org/poezio/slixmpp-omemo/) so it's the winner. [API doc](https://slixmpp.readthedocs.io/). diff --git a/nicobot/jabber.py b/nicobot/jabber.py new file mode 100755 index 0000000..7c8cdae --- /dev/null +++ b/nicobot/jabber.py @@ -0,0 +1,333 @@ +import logging +import time +import os + +from slixmpp import ClientXMPP, JID +from slixmpp.exceptions import IqTimeout, IqError +from slixmpp.stanza import Message +import slixmpp_omemo +from slixmpp_omemo import PluginCouldNotLoad, MissingOwnKey, EncryptionPrepareException +from slixmpp_omemo import UndecidedException, UntrustedException, NoAvailableSession +from omemo.exceptions import MissingBundleException +import asyncio + +# Own classes +from chatter import Chatter +from helpers import * + + +""" Copy-pasted from https://github.com/xmpppy/xmpppy/blob/master/docs/examples/xtalk.py """ +def read_xtalk_file( filename=os.environ['HOME']+'/.xtalk' ): + + jidparams={} + if os.access(filename,os.R_OK): + for ln in open(filename).readlines(): + if not ln[0] in ('#',';'): + key,val=ln.strip().split('=',1) + jidparams[key.lower()]=val + for mandatory in ['jid','password']: + if mandatory not in jidparams.keys(): + raise ValueError('Please point ~/.xtalk config file to valid JID for sending messages. It should look like:\n\n#Uncomment fields before use and type in correct credentials.\n#JID=romeo@montague.net/resource (/resource is optional)\n#PASSWORD=juliet') + return jidparams + + + +class SliXmppClient(ClientXMPP): + + """ + This generic XMPP client is able to send & receive plain & OMEMO-encrypted messages. + + Code is mostly taken from https://lab.louiz.org/poezio/slixmpp-omemo/-/blob/master/examples/echo_client.py + """ + + eme_ns = 'eu.siacs.conversations.axolotl' + + def __init__(self, jid, password, message_handler): + + """ + jid, password : valid account to send and receive messages + message_handler : a Callable( original_message:Message, decrypted_body ) + """ + + ClientXMPP.__init__(self, jid, password) + + self.add_event_handler("session_start", self.session_start) + self.add_event_handler("message", self.message) + + self.register_plugin('xep_0030') # Service Discovery + self.register_plugin('xep_0199') # XMPP Ping + self.register_plugin('xep_0380') # Explicit Message Encryption + + try: + self.register_plugin( + 'xep_0384', + { + 'data_dir': '.omemo', + }, + module=slixmpp_omemo, + ) # OMEMO + except (PluginCouldNotLoad,): + log.exception('And error occured when loading the omemo plugin.') + sys.exit(1) + + self.message_handler = message_handler + + + def session_start(self, event): + self.send_presence() + self.get_roster() + + # Most get_*/set_* methods from plugins use Iq stanzas, which + # can generate IqError and IqTimeout exceptions + # + # try: + # self.get_roster() + # except IqError as err: + # logging.error('There was an error getting the roster') + # logging.error(err.iq['error']['condition']) + # self.disconnect() + # except IqTimeout: + # logging.error('Server is taking too long to respond') + # self.disconnect() + + + async def message(self, msg: Message, allow_untrusted: bool = False) -> None: + """ + Process incoming message stanzas. Be aware that this also + includes MUC messages and error messages. It is usually + a good idea to check the messages's type before processing + or sending replies. + + Arguments: + msg -- The received message stanza. See the documentation + for stanza objects and the Message stanza to see + how it may be used. + """ + + # Sample encrypted message : + # + # + # + #
+ # MwohBadGcbUIiKsYACIw8UjlZHsOEf79+fjBM44O9bM1YatXKRKaIYuaIkajsedDkIK906Srxk2N5B7jh8EozEAFKpfeqZg//Hqrin6wOGpX+TZ5kUJziXIALUaRs59D3J0= + # MwohBWTCE+eWLNmdgTy/gyEAAYACIwzz9+B/UFiaCu+5rcHmh3tyQ/GgBhVa+mk81YkQErXpjCpAPyWbKVJn2TH1dXH4Yj7VYYis0HDQ7r28ZDcMMXoxFcp2VNO9l7S23wI= + # 5eM9IHpWSbKfLJj6 + #
+ # pX7D+54c + #
+ # I sent you an OMEMO encrypted message but your client doesn’t seem to support that. Find more information on https://conversations.im/omemo + #
+ + # Sample plain message : + # + # My message + # + + logging.debug("XMPP message received : %r",msg) + + # TODO ? with xmppy I used to allow the following types : ["message","chat","normal",None] + if msg['type'] not in ('chat', 'normal'): + logging.debug("Discarding message of type %r",msg['type']) + return None + + if not self['xep_0384'].is_encrypted(msg): + logging.debug('This message was not encrypted') + self.message_handler(msg,msg['body']) + return None + + try: + mfrom = msg['from'] + encrypted = msg['omemo_encrypted'] + body = self['xep_0384'].decrypt_message(encrypted, mfrom, allow_untrusted) + # TODO Is it always UTF-8-encoded ? + self.message_handler(msg,body.decode("utf8")) + return None + except (MissingOwnKey,): + # The message is missing our own key, it was not encrypted for + # us, and we can't decrypt it. + logging.exception('I can\'t decrypt this message as it is not encrypted for me : %r',msg) + return None + except (NoAvailableSession,): + # We received a message from that contained a session that we + # don't know about (deleted session storage, etc.). We can't + # decrypt the message, and it's going to be lost. + # Here, as we need to initiate a new encrypted session, it is + # best if we send an encrypted message directly. XXX: Is it + # where we talk about self-healing messages? + logging.exception('I can\'t decrypt this message as it uses an encrypted session I don\'t know about : %r',msg) + return None + except (UndecidedException, UntrustedException) as exn: + # We received a message from an untrusted device. We can + # choose to decrypt the message nonetheless, with the + # `allow_untrusted` flag on the `decrypt_message` call, which + # we will do here. This is only possible for decryption, + # encryption will require us to decide if we trust the device + # or not. Clients _should_ indicate that the message was not + # trusted, or in undecided state, if they decide to decrypt it + # anyway. + logging.exception("Your device '%s' is not in my trusted devices.", exn.device) + # We resend, setting the `allow_untrusted` parameter to True. + await self.message(msg, allow_untrusted=True) + return None + except (EncryptionPrepareException,): + # Slixmpp tried its best, but there were errors it couldn't + # resolve. At this point you should have seen other exceptions + # and given a chance to resolve them already. + logging.exception('I was not able to decrypt the message : %r',msg) + return None + except (Exception,): + logging.exception('An error occured while attempting decryption') + raise + + return None + + + async def plain_send(self, body, receiver, type='chat'): + """ + Helper to send messages + """ + + msg = self.make_message(mto=receiver, mtype=type) + msg['body'] = body + return msg.send() + + + async def plain_reply(self, original_msg, body): + """ + Helper to reply to messages + """ + + return self.plain_send( body, original_msg['from'], original_msg['type'] ) + + + async def encrypted_send(self, body, recipient, type='chat'): + """Helper to send encrypted messages""" + + msg = self.make_message(mto=recipient, mtype=type) + msg['eme']['namespace'] = self.eme_ns + msg['eme']['name'] = self['xep_0380'].mechanisms[self.eme_ns] + + expect_problems = {} # type: Optional[Dict[JID, List[int]]] + + while True: + try: + # `encrypt_message` excepts the plaintext to be sent, a list of + # bare JIDs to encrypt to, and optionally a dict of problems to + # expect per bare JID. + # + # Note that this function returns an `` object, + # and not a full Message stanza. This combined with the + # `recipients` parameter that requires for a list of JIDs, + # allows you to encrypt for 1:1 as well as groupchats (MUC). + # + # `expect_problems`: See EncryptionPrepareException handling. + recipients = [JID(recipient)] + encrypt = await self['xep_0384'].encrypt_message(body, recipients, expect_problems) + msg.append(encrypt) + return msg.send() + except UndecidedException as exn: + # The library prevents us from sending a message to an + # untrusted/undecided barejid, so we need to make a decision here. + # This is where you prompt your user to ask what to do. In + # this bot we will automatically trust undecided recipients. + self['xep_0384'].trust(exn.bare_jid, exn.device, exn.ik) + # TODO: catch NoEligibleDevicesException + except EncryptionPrepareException as exn: + # This exception is being raised when the library has tried + # all it could and doesn't know what to do anymore. It + # contains a list of exceptions that the user must resolve, or + # explicitely ignore via `expect_problems`. + # TODO: We might need to bail out here if errors are the same? + for error in exn.errors: + if isinstance(error, MissingBundleException): + # We choose to ignore MissingBundleException. It seems + # to be somewhat accepted that it's better not to + # encrypt for a device if it has problems and encrypt + # for the rest, rather than error out. The "faulty" + # device won't be able to decrypt and should display a + # generic message. The receiving end-user at this + # point can bring up the issue if it happens. + logging.warning('Could not find keys for device "%d" of recipient "%s". Skipping.', error.device, error.bare_jid) + jid = JID(error.bare_jid) + device_list = expect_problems.setdefault(jid, []) + device_list.append(error.device) + except (IqError, IqTimeout) as exn: + logging.exception('An error occured while fetching information on %r', recipient) + return None + except Exception as exn: + logging.exception('An error occured while attempting to encrypt to %r', recipient) + raise + + return None + + + async def encrypted_reply(self, original_msg, body): + """Helper to reply with encrypted messages""" + + logging.debug("Replying to %r",original_msg) + return self.encrypted_send( body, recipient=original_msg['from'], type=original_msg['type'] ) + + + +class JabberChatter(Chatter): + + """ + Sends and receives messages with XMPP (a.k.a. Jabber). + + It implements nicobot.Chatter by wrapping an internal slixmpp.ClientXMPP instance. + """ + + def __init__( self, jid, password, recipient ): + + self.recipient = recipient + self.xmpp = SliXmppClient( jid, password, message_handler=self.on_xmpp_message ) + + def on_xmpp_message( self, original_message, decrypted_body ): + """ + Called by the internal xmpp client when a message has arrived. + + original_message: The received Message instance + decrypted_body: either the given body if it was plain text or the OMEMO-decrypted one (always a string) + """ + + logging.log(TRACE,"<<< %r",original_message) + logging.debug("<<< %r",decrypted_body) + self.bot.onMessage(decrypted_body) + + def connect(self): + + logging.debug("Connecting...") + # Connects and waits for the connection to be established + # See https://slixmpp.readthedocs.io/using_asyncio.html + self.xmpp.connected_event = asyncio.Event() + callback = lambda _: self.xmpp.connected_event.set() + self.xmpp.add_event_handler('session_start', callback) + self.xmpp.connect() + loop = asyncio.get_event_loop() + loop.run_until_complete(self.xmpp.connected_event.wait()) + logging.debug("Connected.") + + def start( self, bot ): + """ + Waits for messages and calls the 'onMessage' method of the given Bot + """ + self.bot = bot + # do some other stuff before running the event loop, e.g. + # loop.run_until_complete(httpserver.init()) + self.xmpp.process(forever=False) + # FIXME Following error when exiting : + # Task was destroyed but it is pending! task: wait_for=()]>> + + def send( self, message ): + """ + Sends the given message using the underlying implemented chat protocol + """ + logging.debug(">>> %s",message) + loop = asyncio.get_event_loop() + loop.run_until_complete(self.xmpp.encrypted_send( body=message, recipient=self.recipient )) + + def stop( self ): + """ + Stops waiting for messages and exits the engine + """ + self.xmpp.disconnect() diff --git a/requirements.txt b/requirements.txt index ddd405d..811df83 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,3 +12,6 @@ requests emoji-country-flag # https://pyyaml.org/wiki/PyYAMLDocumentation pyyaml + +##### Requirements for jabber ##### +slixmpp-omemo