From ce57a9c93209e1e34007d0321cfdace59ec597d7 Mon Sep 17 00:00:00 2001 From: nicobo Date: Fri, 22 May 2020 07:29:52 +0200 Subject: [PATCH 1/7] + 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 From 40419d714d0ebcedf32b115a0ba9ba514044cfef Mon Sep 17 00:00:00 2001 From: nicobo Date: Fri, 22 May 2020 07:32:03 +0200 Subject: [PATCH 2/7] + connect method to Chatter + jabber backend to askbot ~ fixed : config.recipient -> config.recipients --- nicobot/askbot.py | 38 +++++++++++++++++++++++++++++++++----- nicobot/chatter.py | 7 +++++++ nicobot/console.py | 3 ++- nicobot/stealth.py | 3 ++- nicobot/transbot.py | 5 +++++ 5 files changed, 49 insertions(+), 7 deletions(-) diff --git a/nicobot/askbot.py b/nicobot/askbot.py index b645c81..785ec90 100644 --- a/nicobot/askbot.py +++ b/nicobot/askbot.py @@ -21,6 +21,7 @@ import urllib.request from helpers import * from bot import Bot from console import ConsoleChatter +from jabber import * from signalcli import SignalChatter from stealth import StealthChatter @@ -128,8 +129,11 @@ class AskBot(Bot): logging.debug("Bot ready.") self.registerExitHandler() + + self.chatter.connect() if self.message: self.chatter.send(self.message) + # Blocks on this line until the bot exits logging.debug("Bot reading answer...") self.chatter.start(self) @@ -161,10 +165,10 @@ if __name__ == '__main__': parser.add_argument("--config-dir", "-C", dest="config_dir", default=config.config_dir, help="Directory where to find configuration files by default.") parser.add_argument('--verbosity', '-V', dest='verbosity', default=config.verbosity, help="Log level") # Chatter-generic arguments - parser.add_argument("--backend", "-b", dest="backend", choices=["signal","console"], default=config.backend, help="Chat backend to use") + parser.add_argument("--backend", "-b", dest="backend", choices=['console','jabber','xmpp','signal'], default=config.backend, help="Chat backend to use") parser.add_argument("--input-file", "-i", dest="input_file", default=config.input_file, help="File to read messages from (one per line)") parser.add_argument('--username', '-U', '--jabberid', dest='username', help="Sender's ID (a phone number for Signal, a Jabber Identifier (JID) aka. username for Jabber/XMPP") - parser.add_argument('--recipient', '-r', '--receiver', dest='recipient', action='append', help="Recipient's ID (e.g. '+12345678901' for Signal / JabberID (Receiver address) to send the message to)") + parser.add_argument('--recipient', '-r', '--receiver', dest='recipients', action='append', help="Recipient's ID (e.g. '+12345678901' for Signal / JabberID (Receiver address) to send the message to)") parser.add_argument('--group', '-g', dest='group', help="Group's ID (for Signal : a base64 string (e.g. 'mPC9JNVoKDGz0YeZMsbL1Q==')") parser.add_argument('--stealth', dest='stealth', action="store_true", default=config.stealth, help="Activate stealth mode on any chosen chatter") # Other core options @@ -246,23 +250,47 @@ if __name__ == '__main__': # # Creates the chat engine depending on the 'backend' parameter - if config.backend == "signal": + if config.backend in ['jabber','xmpp']: + logging.debug("Jabber/XMPP backend selected") + # Gets jid and password + if not config.username or not config.password: + logging.debug("Missing username or password : reading from .xtalk") + xtalkConf = read_xtalk_file() + logging.debug("Got from .xtalk : %r",xtalkConf) + config.username = xtalkConf['jid'] + config.password = xtalkConf['password'] + if not config.username: + raise ValueError("Missing --username and no ~.xtalk file") + if not config.password: + raise ValueError("Missing --password and no ~.xtalk file") + if len(config.recipients)==0 and not config.group: + raise ValueError("Missing --recipient") + chatter = JabberChatter( + jid=config.username, + password=config.password, + recipient=config.recipients[0] + ) + + elif config.backend == 'signal': + logging.debug("Signal backend selected") if not config.signal_cli: raise ValueError("Could not find the 'signal-cli' command in PATH and no --signal-cli given") if not config.username: raise ValueError("Missing a username") - if not config.recipient and not config.group: + if len(config.recipient)==0 and not config.group: raise ValueError("Either --recipient or --group must be provided") chatter = SignalChatter( username=config.username, - recipient=config.recipient[0], + recipient=config.recipients[0], group=config.group, signal_cli=config.signal_cli, stealth=config.signal_stealth ) # TODO :timeout=config.timeout + # By default (or if backend == "console"), will read from stdin or a given file and output to console else: + logging.debug("Console backend selected") chatter = ConsoleChatter(config.input_file,sys.stdout) if config.stealth: diff --git a/nicobot/chatter.py b/nicobot/chatter.py index b98da4e..9afdf61 100644 --- a/nicobot/chatter.py +++ b/nicobot/chatter.py @@ -6,6 +6,13 @@ class Chatter: Bot engine interface """ + def connect(self): + """ + Connects / initializes the connection with the underlying protocol/network if required. + This should always be called before any other method in order to make sure the bot is connected. + """ + pass + def start( self, bot ): """ Waits for messages and calls the 'onMessage' method of the given Bot diff --git a/nicobot/console.py b/nicobot/console.py index e48400a..f8f115a 100644 --- a/nicobot/console.py +++ b/nicobot/console.py @@ -2,9 +2,10 @@ import logging import sys +from chatter import Chatter -class ConsoleChatter: +class ConsoleChatter(Chatter): """ Bot engine that reads from a stream and outputs to another """ diff --git a/nicobot/stealth.py b/nicobot/stealth.py index 3479230..1a797ba 100644 --- a/nicobot/stealth.py +++ b/nicobot/stealth.py @@ -2,9 +2,10 @@ import logging import sys +from chatter import Chatter -class StealthChatter: +class StealthChatter(Chatter): """ Wraps a bot engine and prints messages rather than sending them """ diff --git a/nicobot/transbot.py b/nicobot/transbot.py index 9b9f5f2..93e21e4 100755 --- a/nicobot/transbot.py +++ b/nicobot/transbot.py @@ -509,6 +509,8 @@ class TransBot(Bot): 2. Waits for messages to translate """ + self.chatter.connect() + # TODO Better using gettext, in the end try: hello = i18n.t('Hello') @@ -519,6 +521,7 @@ class TransBot(Bot): except KeyError: logging.debug("No 'Hello' text : nothing was sent") pass + self.registerExitHandler() self.chatter.start(self) logging.debug("Chatter loop ended") @@ -531,6 +534,8 @@ if __name__ == '__main__': A convenient CLI to play with this bot """ + # TODO Update with the latest options from askbot (and make the generic ones go into bot.py) + # # Two-pass arguments parsing # From 646847e7ac2f273d2ca5dbfdd3224b85598c2baf Mon Sep 17 00:00:00 2001 From: nicobo Date: Fri, 22 May 2020 08:37:17 +0200 Subject: [PATCH 3/7] - remove ~/.xtalk support (does not look standard) ~ options username, password, group are now backend-specific, they only share convenient default options ~ options names fixed for config file compatibility --- README.md | 12 ++---- nicobot/askbot.py | 65 +++++++++++++++--------------- nicobot/jabber.py | 15 ------- test/askbot-sample-conf/config.yml | 15 +++++-- 4 files changed, 48 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index 4b12bbc..2b3a363 100644 --- a/README.md +++ b/README.md @@ -180,8 +180,6 @@ The following options are common to both bots : - **--config-file** and **--config-dir** let you change the default configuration directory and file. All configuration files will be looked up from this directory ; `--config-file` allows overriding the location of `config.yml`. - **--backend** selects the *chatter* system to use : it currently supports "console" and "signal" (see below) -- **--username** selects the account to use to send and read message ; its format depends on the backend -- **--recipient** and **--group** select the recipient (only one of them should be given) ; its format depends on the backend - **--stealth** will make the bot connect and listen to messages but print any answer instead of sending it ; useful to observe the bot's behavior in a real chatroom... @@ -207,8 +205,8 @@ By using `--backend jabber` you can make the bot chat with XMPP (a.k.a. Jabber) ### 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 +- `--jabber-username` and `--jabber-password` are the JabberID (e.g. *myusername@myserver.im*) and password of the bot's account used to send and read messages. If `--jabber-username` missing, `--username` will be used. +- `--jabber-recipient` is the JabberID of the person to send the message to. If missing, `--recipient` will be used. @@ -229,10 +227,8 @@ Please see the [man page](https://github.com/AsamK/signal-cli/blob/master/man/si ### Signal-specific options -With signal, make sure : - -- the `--username` parameter is your phone number in international format (e.g. `+33123456789`). In `config.yml`, make sure to put quotes around it to prevent YAML thinking it's an integer (because of the 'plus' sign) -- specify either `--recipient` as an international phone number or `--group` with a base 64 group ID (e.g. `--group "mABCDNVoEFGz0YeZM1234Q=="`). Once registered with Signal, you can list the IDs of the groups you are in with `signal-cli -U +336123456789 listGroups` +- `--signal-username` selects the account to use to send and read message : it is a phone number in international format (e.g. `+33123456789`). In `config.yml`, make sure to put quotes around it to prevent YAML thinking it's an integer (because of the 'plus' sign). If missing, `--username` will be used. +- `--signal-recipient` and `--signal-group` select the recipient (only one of them should be given). Make sure `--signal-recipient` is in international phone number format and `--signal-group` is a base 64 group ID (e.g. `--signal-group "mABCDNVoEFGz0YeZM1234Q=="`). If `--signal-recipient` is missing, `--recipient` will be used. Once registered with Signal, you can list the IDs of the groups you are in with `signal-cli -U +336123456789 listGroups` Sample command line to run the bot with Signal : diff --git a/nicobot/askbot.py b/nicobot/askbot.py index 785ec90..3f8bb50 100644 --- a/nicobot/askbot.py +++ b/nicobot/askbot.py @@ -34,7 +34,6 @@ class Config: 'backend': "console", 'config_file': "config.yml", 'config_dir': os.getcwd(), - 'group': None, 'input_file': sys.stdin, 'max_count': -1, 'patterns': [], @@ -147,8 +146,6 @@ if __name__ == '__main__': """ A convenient CLI to play with this bot. - Arguments are compatible with https://github.com/xmpppy/xmpppy/blob/master/xmpp/cli.py and `$HOME/.xtalk` - but new ones are added. TODO Put generic arguments in bot.py and inherit from it (should probably provide a parent ArgumentParser) """ @@ -165,14 +162,12 @@ if __name__ == '__main__': parser.add_argument("--config-dir", "-C", dest="config_dir", default=config.config_dir, help="Directory where to find configuration files by default.") parser.add_argument('--verbosity', '-V', dest='verbosity', default=config.verbosity, help="Log level") # Chatter-generic arguments - parser.add_argument("--backend", "-b", dest="backend", choices=['console','jabber','xmpp','signal'], default=config.backend, help="Chat backend to use") + parser.add_argument("--backend", "-b", dest="backend", choices=['console','jabber','signal'], default=config.backend, help="Chat backend to use") parser.add_argument("--input-file", "-i", dest="input_file", default=config.input_file, help="File to read messages from (one per line)") - parser.add_argument('--username', '-U', '--jabberid', dest='username', help="Sender's ID (a phone number for Signal, a Jabber Identifier (JID) aka. username for Jabber/XMPP") - parser.add_argument('--recipient', '-r', '--receiver', dest='recipients', action='append', help="Recipient's ID (e.g. '+12345678901' for Signal / JabberID (Receiver address) to send the message to)") - parser.add_argument('--group', '-g', dest='group', help="Group's ID (for Signal : a base64 string (e.g. 'mPC9JNVoKDGz0YeZMsbL1Q==')") + parser.add_argument('--username', '-U', dest='username', help="Sender's ID (a phone number for Signal, a Jabber Identifier (JID) aka. username for Jabber/XMPP") + parser.add_argument('--recipient', '-r', '--receiver', dest='recipients', default=[], action='append', help="Recipient's ID (e.g. '+12345678901' for Signal / JabberID (Receiver address) to send the message to)") parser.add_argument('--stealth', dest='stealth', action="store_true", default=config.stealth, help="Activate stealth mode on any chosen chatter") # Other core options - parser.add_argument('--password', '-P', dest='password', help="Senders's password") parser.add_argument('--max-count', dest='max_count', type=int, default=config.max_count, help="Read this maximum number of responses before exiting") parser.add_argument('--message', '-m', dest='message', help="Message to send. If missing, will read from --input-file") parser.add_argument('--message-file', '-f', dest='message_file', type=argparse.FileType('r'), default=sys.stdin, help="File with the message to send. If missing, will be read from standard input") @@ -182,9 +177,14 @@ if __name__ == '__main__': parser.add_argument("--debug", "-d", action="store_true", dest='debug', default=False, help="Activate debug logs (overrides --verbosity)") # Signal-specific arguments parser.add_argument('--signal-cli', dest='signal_cli', default=config.signal_cli, help="Path to `signal-cli` if not in PATH") + parser.add_argument('--signal-username', dest='signal_username', help="Username when using the Signal backend (overrides --username)") + parser.add_argument('--signal-group', dest='group', help="Group's ID (for Signal : a base64 string (e.g. 'mPC9JNVoKDGz0YeZMsbL1Q==')") + parser.add_argument('--signal-recipient', dest='signal_recipients', action='append', default=[], help="Recipient when using the Signal backend (overrides --recipient)") parser.add_argument('--signal-stealth', dest='signal_stealth', action="store_true", default=config.signal_stealth, help="Activate Signal chatter's specific stealth mode") # Jabber-specific arguments - # TODO + parser.add_argument('--jabber-username', '--jabberid', '--jid', dest='jabber_username', help="Username when using the Jabber/XMPP backend (overrides --username)") + parser.add_argument('--jabber-recipient', dest='jabber_recipients', action='append', default=[], help="Recipient when using the Jabber/XMPP backend (overrides --recipient)") + parser.add_argument('--jabber-password', dest='jabber_password', help="Senders's password") # # 1st pass only matters for 'bootstrap' options : configuration file and logging @@ -250,39 +250,38 @@ if __name__ == '__main__': # # Creates the chat engine depending on the 'backend' parameter - if config.backend in ['jabber','xmpp']: + if config.backend == 'jabber': logging.debug("Jabber/XMPP backend selected") - # Gets jid and password - if not config.username or not config.password: - logging.debug("Missing username or password : reading from .xtalk") - xtalkConf = read_xtalk_file() - logging.debug("Got from .xtalk : %r",xtalkConf) - config.username = xtalkConf['jid'] - config.password = xtalkConf['password'] - if not config.username: - raise ValueError("Missing --username and no ~.xtalk file") - if not config.password: - raise ValueError("Missing --password and no ~.xtalk file") - if len(config.recipients)==0 and not config.group: - raise ValueError("Missing --recipient") + username = config.jabber_username if config.jabber_username else config.username + if not username: + raise ValueError("Missing --jabber-username") + if not config.jabber_password: + raise ValueError("Missing --jabber-password") + recipients = config.jabber_recipients + config.recipients + if len(recipients)==0: + raise ValueError("Missing --jabber-recipient") + # TODO allow multiple recipients chatter = JabberChatter( - jid=config.username, - password=config.password, - recipient=config.recipients[0] + jid=username, + password=config.jabber_password, + recipient=recipients[0] ) elif config.backend == 'signal': logging.debug("Signal backend selected") if not config.signal_cli: raise ValueError("Could not find the 'signal-cli' command in PATH and no --signal-cli given") - if not config.username: - raise ValueError("Missing a username") - if len(config.recipient)==0 and not config.group: - raise ValueError("Either --recipient or --group must be provided") + username = config.signal_username if config.signal_username else config.username + if not username: + raise ValueError("Missing --signal-username") + recipients = config.signal_recipients + config.recipients + if len(recipients)==0 and not config.signal_group: + raise ValueError("Either --signal-recipient or --signal-group must be provided") + # TODO allow multiple recipients chatter = SignalChatter( - username=config.username, - recipient=config.recipients[0], - group=config.group, + username=username, + recipient=recipients[0], + group=config.signal_group, signal_cli=config.signal_cli, stealth=config.signal_stealth ) diff --git a/nicobot/jabber.py b/nicobot/jabber.py index 7c8cdae..5b98b77 100755 --- a/nicobot/jabber.py +++ b/nicobot/jabber.py @@ -16,21 +16,6 @@ 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): diff --git a/test/askbot-sample-conf/config.yml b/test/askbot-sample-conf/config.yml index ba69bdf..314b2bf 100644 --- a/test/askbot-sample-conf/config.yml +++ b/test/askbot-sample-conf/config.yml @@ -5,10 +5,19 @@ patterns: - [ "cancel", "(?i)\\b(cancel|abort)\\b" ] backend: console +#backend: jabber #backend: signal +# Used when backend = signal # Make sure to put quotes around the username field as it is a phone number for Signal -username: "+33123456789" -recipient: "+33123456789" +signal_username: "+33123456789" +signal_recipients: + - "+33123456789" # Get this group ID with the command `signal-cli -u +33123456789 listGroups` -#group: "mABCDNVoEFGz0YeZM1234Q==" +#signal_group: "mABCDNVoEFGz0YeZM1234Q==" + +# Used when backend = jabber +jabber_username: mybot@conversations.im +jabber_password: TheBestPasswordInTheWorld +jabber_recipients: + - itsme@conversations.im From cb58f3f2a6597ada6aab9435a3afee17a96e285d Mon Sep 17 00:00:00 2001 From: nicobo Date: Fri, 22 May 2020 09:15:50 +0200 Subject: [PATCH 4/7] ~ common/specific options parsing disptached into bot.py, signalcli.py and jabber.py --- nicobot/askbot.py | 39 +++++++++-------------------------- nicobot/bot.py | 48 +++++++++++++++++++++++++++++++++++++++++++- nicobot/jabber.py | 23 +++++++++++++++++++-- nicobot/signalcli.py | 48 ++++++++++++++++++++++++++++++++++++-------- 4 files changed, 118 insertions(+), 40 deletions(-) diff --git a/nicobot/askbot.py b/nicobot/askbot.py index 3f8bb50..1eb0c4c 100644 --- a/nicobot/askbot.py +++ b/nicobot/askbot.py @@ -20,9 +20,12 @@ import urllib.request # Own classes from helpers import * from bot import Bot +from bot import ArgHelper as BotArgHelper from console import ConsoleChatter -from jabber import * +from jabber import JabberChatter +from jabber import arg_parser as jabber_arg_parser from signalcli import SignalChatter +from signalcli import ArgHelper as SignalArgHelper from stealth import StealthChatter @@ -37,12 +40,8 @@ class Config: 'input_file': sys.stdin, 'max_count': -1, 'patterns': [], - 'recipient': None, - 'signal_cli': shutil.which("signal-cli"), - 'signal_stealth': False, 'stealth': False, 'timeout': None, - 'username': None, 'verbosity': "INFO", }) @@ -130,6 +129,7 @@ class AskBot(Bot): self.registerExitHandler() self.chatter.connect() + # FIXME Sometimes the message is not received by the recipient (but the logs show it's sent ?) if self.message: self.chatter.send(self.message) @@ -156,35 +156,16 @@ if __name__ == '__main__': # config is the final, merged configuration config = Config() - parser = argparse.ArgumentParser( description='Sends a XMPP message and reads the answer', formatter_class=argparse.ArgumentDefaultsHelpFormatter ) - # Bootstrap options - parser.add_argument("--config-file", "-c", "--config", dest="config_file", default=config.config_file, help="YAML configuration file.") - parser.add_argument("--config-dir", "-C", dest="config_dir", default=config.config_dir, help="Directory where to find configuration files by default.") - parser.add_argument('--verbosity', '-V', dest='verbosity', default=config.verbosity, help="Log level") - # Chatter-generic arguments - parser.add_argument("--backend", "-b", dest="backend", choices=['console','jabber','signal'], default=config.backend, help="Chat backend to use") - parser.add_argument("--input-file", "-i", dest="input_file", default=config.input_file, help="File to read messages from (one per line)") - parser.add_argument('--username', '-U', dest='username', help="Sender's ID (a phone number for Signal, a Jabber Identifier (JID) aka. username for Jabber/XMPP") - parser.add_argument('--recipient', '-r', '--receiver', dest='recipients', default=[], action='append', help="Recipient's ID (e.g. '+12345678901' for Signal / JabberID (Receiver address) to send the message to)") - parser.add_argument('--stealth', dest='stealth', action="store_true", default=config.stealth, help="Activate stealth mode on any chosen chatter") - # Other core options + parser = argparse.ArgumentParser( + parents=[ BotArgHelper().arg_parser(), jabber_arg_parser(), SignalArgHelper().arg_parser() ], + description='Sends a XMPP message and reads the answer', + formatter_class=argparse.ArgumentDefaultsHelpFormatter ) + # Core options for this bot parser.add_argument('--max-count', dest='max_count', type=int, default=config.max_count, help="Read this maximum number of responses before exiting") parser.add_argument('--message', '-m', dest='message', help="Message to send. If missing, will read from --input-file") parser.add_argument('--message-file', '-f', dest='message_file', type=argparse.FileType('r'), default=sys.stdin, help="File with the message to send. If missing, will be read from standard input") parser.add_argument('--pattern', '-p', dest='patterns', action='append', nargs=2, help="Exits with status 0 whenever a message matches this pattern ; otherwise with status 1") parser.add_argument('--timeout', '-t', dest='timeout', type=int, default=config.timeout, help="How much time t wait for an answer before quiting (in seconds)") - # Misc. options - parser.add_argument("--debug", "-d", action="store_true", dest='debug', default=False, help="Activate debug logs (overrides --verbosity)") - # Signal-specific arguments - parser.add_argument('--signal-cli', dest='signal_cli', default=config.signal_cli, help="Path to `signal-cli` if not in PATH") - parser.add_argument('--signal-username', dest='signal_username', help="Username when using the Signal backend (overrides --username)") - parser.add_argument('--signal-group', dest='group', help="Group's ID (for Signal : a base64 string (e.g. 'mPC9JNVoKDGz0YeZMsbL1Q==')") - parser.add_argument('--signal-recipient', dest='signal_recipients', action='append', default=[], help="Recipient when using the Signal backend (overrides --recipient)") - parser.add_argument('--signal-stealth', dest='signal_stealth', action="store_true", default=config.signal_stealth, help="Activate Signal chatter's specific stealth mode") - # Jabber-specific arguments - parser.add_argument('--jabber-username', '--jabberid', '--jid', dest='jabber_username', help="Username when using the Jabber/XMPP backend (overrides --username)") - parser.add_argument('--jabber-recipient', dest='jabber_recipients', action='append', default=[], help="Recipient when using the Jabber/XMPP backend (overrides --recipient)") - parser.add_argument('--jabber-password', dest='jabber_password', help="Senders's password") # # 1st pass only matters for 'bootstrap' options : configuration file and logging diff --git a/nicobot/bot.py b/nicobot/bot.py index 4b56abf..5a12859 100644 --- a/nicobot/bot.py +++ b/nicobot/bot.py @@ -1,9 +1,13 @@ # -*- coding: utf-8 -*- +import argparse import atexit +import logging +import os import signal import sys -import logging + + class Bot: """ @@ -46,3 +50,45 @@ class Bot: Starts the bot """ pass + + + +class ArgHelper: + + """ + Command-line parsing helper for bot-generic options + """ + + def __init__(self): + + # Default configuration (some defaults still need to be set up after command line has been parsed) + self.__dict__.update({ + 'backend': "console", + 'config_file': "config.yml", + 'config_dir': os.getcwd(), + 'input_file': sys.stdin, + 'stealth': False, + 'verbosity': "INFO", + }) + + def arg_parser(self): + """ + Returns a parent parser for common bot arguments + """ + + parser = argparse.ArgumentParser(add_help=False) + + # Bootstrap options + parser.add_argument("--config-file", "-c", "--config", dest="config_file", default=self.config_file, help="YAML configuration file.") + parser.add_argument("--config-dir", "-C", dest="config_dir", default=self.config_dir, help="Directory where to find configuration files by default.") + parser.add_argument('--verbosity', '-V', dest='verbosity', default=self.verbosity, help="Log level") + # Chatter-generic arguments + parser.add_argument("--backend", "-b", dest="backend", choices=['console','jabber','signal'], default=self.backend, help="Chat backend to use") + parser.add_argument("--input-file", "-i", dest="input_file", default=self.input_file, help="File to read messages from (one per line)") + parser.add_argument('--username', '-U', dest='username', help="Sender's ID (a phone number for Signal, a Jabber Identifier (JID) aka. username for Jabber/XMPP") + parser.add_argument('--recipient', '-r', '--receiver', dest='recipients', default=[], action='append', help="Recipient's ID (e.g. '+12345678901' for Signal / JabberID (Receiver address) to send the message to)") + parser.add_argument('--stealth', dest='stealth', action="store_true", default=self.stealth, help="Activate stealth mode on any chosen chatter") + # Misc. options + parser.add_argument("--debug", "-d", action="store_true", dest='debug', default=False, help="Activate debug logs (overrides --verbosity)") + + return parser diff --git a/nicobot/jabber.py b/nicobot/jabber.py index 5b98b77..0a43d72 100755 --- a/nicobot/jabber.py +++ b/nicobot/jabber.py @@ -1,6 +1,10 @@ +# -*- coding: utf-8 -*- + +import argparse +import asyncio import logging -import time import os +import time from slixmpp import ClientXMPP, JID from slixmpp.exceptions import IqTimeout, IqError @@ -9,7 +13,6 @@ 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 @@ -316,3 +319,19 @@ class JabberChatter(Chatter): Stops waiting for messages and exits the engine """ self.xmpp.disconnect() + + + +def arg_parser(): + """ + Returns a parent parser for jabber-specific arguments + """ + + parser = argparse.ArgumentParser(add_help=False) + + # Jabber-specific arguments + parser.add_argument('--jabber-username', '--jabberid', '--jid', dest='jabber_username', help="Username when using the Jabber/XMPP backend (overrides --username)") + parser.add_argument('--jabber-recipient', dest='jabber_recipients', action='append', default=[], help="Recipient when using the Jabber/XMPP backend (overrides --recipient)") + parser.add_argument('--jabber-password', dest='jabber_password', help="Senders's password") + + return parser diff --git a/nicobot/signalcli.py b/nicobot/signalcli.py index 44aba92..4ee01b4 100755 --- a/nicobot/signalcli.py +++ b/nicobot/signalcli.py @@ -2,17 +2,17 @@ # -*- coding: utf-8 -*- import argparse -import logging -import sys -import os -import shutil -import subprocess import atexit -import signal -import json import i18n -import re +import json import locale +import logging +import os +import re +import shutil +import signal +import subprocess +import sys import time from chatter import Chatter @@ -159,3 +159,35 @@ class SignalChatter(Chatter): logging.debug("Discarding message without data") else: logging.debug("Discarding message that was sent before I started") + + + +class ArgHelper: + + """ + Command-line parsing helper for Signal-specific options + """ + + def __init__(self): + + # Default configuration (some defaults still need to be set up after command line has been parsed) + self.__dict__.update({ + 'signal_cli': shutil.which("signal-cli"), + 'signal_stealth': False, + }) + + def arg_parser(self): + """ + Returns a parent parser for Signal-specific arguments + """ + + parser = argparse.ArgumentParser(add_help=False) + + # Signal-specific arguments + parser.add_argument('--signal-cli', dest='signal_cli', default=self.signal_cli, help="Path to `signal-cli` if not in PATH") + parser.add_argument('--signal-username', dest='signal_username', help="Username when using the Signal backend (overrides --username)") + parser.add_argument('--signal-group', dest='signal_group', help="Group's ID (for Signal : a base64 string (e.g. 'mPC9JNVoKDGz0YeZMsbL1Q==')") + parser.add_argument('--signal-recipient', dest='signal_recipients', action='append', default=[], help="Recipient when using the Signal backend (overrides --recipient)") + parser.add_argument('--signal-stealth', dest='signal_stealth', action="store_true", default=self.signal_stealth, help="Activate Signal chatter's specific stealth mode") + + return parser From ebeb3b775637c0f9066bc30ffcfffc542f06ba6f Mon Sep 17 00:00:00 2001 From: nicobo Date: Fri, 22 May 2020 14:54:58 +0200 Subject: [PATCH 5/7] ~ More arg parsing refactoring : centralizing code --- nicobot/askbot.py | 110 +++---------------------------------------- nicobot/bot.py | 80 ++++++++++++++++++++++++++++++- nicobot/helpers.py | 62 +++++++++++++++++++++++- nicobot/signalcli.py | 4 +- nicobot/transbot.py | 96 +++++-------------------------------- 5 files changed, 160 insertions(+), 192 deletions(-) diff --git a/nicobot/askbot.py b/nicobot/askbot.py index 1eb0c4c..49ba52f 100644 --- a/nicobot/askbot.py +++ b/nicobot/askbot.py @@ -20,12 +20,12 @@ import urllib.request # Own classes from helpers import * from bot import Bot -from bot import ArgHelper as BotArgHelper +from bot import ArgsHelper as BotArgsHelper from console import ConsoleChatter from jabber import JabberChatter from jabber import arg_parser as jabber_arg_parser from signalcli import SignalChatter -from signalcli import ArgHelper as SignalArgHelper +from signalcli import ArgsHelper as SignalArgsHelper from stealth import StealthChatter @@ -149,15 +149,11 @@ if __name__ == '__main__': TODO Put generic arguments in bot.py and inherit from it (should probably provide a parent ArgumentParser) """ - # - # Two-pass arguments parsing - # - - # config is the final, merged configuration + # config will be the final, merged configuration config = Config() parser = argparse.ArgumentParser( - parents=[ BotArgHelper().arg_parser(), jabber_arg_parser(), SignalArgHelper().arg_parser() ], + parents=[ BotArgsHelper().parser(), jabber_arg_parser(), SignalArgsHelper().parser() ], description='Sends a XMPP message and reads the answer', formatter_class=argparse.ArgumentDefaultsHelpFormatter ) # Core options for this bot @@ -168,58 +164,9 @@ if __name__ == '__main__': parser.add_argument('--timeout', '-t', dest='timeout', type=int, default=config.timeout, help="How much time t wait for an answer before quiting (in seconds)") # - # 1st pass only matters for 'bootstrap' options : configuration file and logging + # Two-pass arguments parsing # - # Note : we don't let the parse_args method merge the 'args' into config yet, - # because it would not be possible to make the difference between the default values - # and the ones explictely given by the user - # This is usefull for instance to throw an exception if a file given by the user doesn't exist, which is different than the default filename - # 'config' is therefore the defaults overriden by user options while 'args' has only user options - args = parser.parse_args() - - # Logging configuration - configure_logging(args.verbosity,debug=args.debug) - logging.debug( "Configuration for bootstrap : %s", repr(vars(args)) ) - - # Fills the config with user-defined default options from a config file - try: - # Allows config_file to be relative to the config_dir - config.config_file = filter_files( - [args.config_file, - os.path.join(args.config_dir,"config.yml")], - should_exist=True, - fallback_to=1 )[0] - logging.debug("Using config file %s",config.config_file) - with open(config.config_file,'r') as file: - # The FullLoader parameter handles the conversion from YAML - # scalar values to Python the dictionary format - try: - # This is the required syntax in newer pyyaml distributions - dictConfig = yaml.load(file, Loader=yaml.FullLoader) - except AttributeError: - # Some systems (e.g. raspbian) ship with an older version of pyyaml - dictConfig = yaml.load(file) - logging.debug("Successfully loaded configuration from %s : %s" % (config.config_file,repr(dictConfig))) - config.__dict__.update(dictConfig) - except OSError as e: - # If it was a user-set option, stop here - if args.config_file == config.config_file: - raise e - else: - logging.debug("Could not open %s ; no config file will be used",config.config_file) - logging.debug(e, exc_info=True) - pass - # From here the config object has only the default values for all configuration options - - # - # 2nd pass parses all options - # - # Updates again the existing config object with all parsed options - config = parser.parse_args(namespace=config) - # From the bootstrap parameters, only logging level may need to be read again - configure_logging(config.verbosity,debug=config.debug) - logging.debug( "Final configuration : %s", repr(vars(config)) ) - + config = parse_args_2pass( parser, config ) # # From here the config object has default options from: # 1. hard-coded default values @@ -231,50 +178,7 @@ if __name__ == '__main__': # # Creates the chat engine depending on the 'backend' parameter - if config.backend == 'jabber': - logging.debug("Jabber/XMPP backend selected") - username = config.jabber_username if config.jabber_username else config.username - if not username: - raise ValueError("Missing --jabber-username") - if not config.jabber_password: - raise ValueError("Missing --jabber-password") - recipients = config.jabber_recipients + config.recipients - if len(recipients)==0: - raise ValueError("Missing --jabber-recipient") - # TODO allow multiple recipients - chatter = JabberChatter( - jid=username, - password=config.jabber_password, - recipient=recipients[0] - ) - - elif config.backend == 'signal': - logging.debug("Signal backend selected") - if not config.signal_cli: - raise ValueError("Could not find the 'signal-cli' command in PATH and no --signal-cli given") - username = config.signal_username if config.signal_username else config.username - if not username: - raise ValueError("Missing --signal-username") - recipients = config.signal_recipients + config.recipients - if len(recipients)==0 and not config.signal_group: - raise ValueError("Either --signal-recipient or --signal-group must be provided") - # TODO allow multiple recipients - chatter = SignalChatter( - username=username, - recipient=recipients[0], - group=config.signal_group, - signal_cli=config.signal_cli, - stealth=config.signal_stealth - ) - # TODO :timeout=config.timeout - - # By default (or if backend == "console"), will read from stdin or a given file and output to console - else: - logging.debug("Console backend selected") - chatter = ConsoleChatter(config.input_file,sys.stdout) - - if config.stealth: - chatter = StealthChatter(chatter) + chatter = BotArgsHelper.chatter(config) # # Real start diff --git a/nicobot/bot.py b/nicobot/bot.py index 5a12859..4d0d085 100644 --- a/nicobot/bot.py +++ b/nicobot/bot.py @@ -8,6 +8,11 @@ import signal import sys +from console import ConsoleChatter +from jabber import JabberChatter +from signalcli import SignalChatter +from stealth import StealthChatter + class Bot: """ @@ -53,7 +58,7 @@ class Bot: -class ArgHelper: +class ArgsHelper: """ Command-line parsing helper for bot-generic options @@ -71,7 +76,8 @@ class ArgHelper: 'verbosity': "INFO", }) - def arg_parser(self): + + def parser(self): """ Returns a parent parser for common bot arguments """ @@ -92,3 +98,73 @@ class ArgHelper: parser.add_argument("--debug", "-d", action="store_true", dest='debug', default=False, help="Activate debug logs (overrides --verbosity)") return parser + + + def jabber_chatter( args ): + """ + Builds a JabberChatter from Namespace argument 'args' + """ + + username = args.jabber_username if args.jabber_username else args.username + if not username: + raise ValueError("Missing --jabber-username") + if not args.jabber_password: + raise ValueError("Missing --jabber-password") + recipients = args.jabber_recipients + args.recipients + if len(recipients)==0: + raise ValueError("Missing --jabber-recipient") + # TODO allow multiple recipients + return JabberChatter( + jid=username, + password=args.jabber_password, + recipient=recipients[0] + ) + + + def signal_chatter( args ): + """ + Builds a SignalChatter from Namespace argument 'args' + """ + + if not args.signal_cli: + raise ValueError("Could not find the 'signal-cli' command in PATH and no --signal-cli given") + username = args.signal_username if args.signal_username else args.username + if not username: + raise ValueError("Missing --signal-username") + recipients = args.signal_recipients + args.recipients + if len(recipients)==0 and not args.signal_group: + raise ValueError("Either --signal-recipient or --signal-group must be provided") + # TODO allow multiple recipients + return SignalChatter( + username=username, + recipient=recipients[0], + group=args.signal_group, + signal_cli=args.signal_cli, + stealth=args.signal_stealth + ) + # TODO :timeout=args.timeout + + + def chatter( args ): + """ + Builds the Chatter corresponding to the given parsed command-line arguments + args: command-line arguments as a Namespace (see argparse) + """ + + if args.backend == 'jabber': + logging.debug("Jabber/XMPP backend selected") + chatter = ArgsHelper.jabber_chatter(args) + + elif args.backend == 'signal': + logging.debug("Signal backend selected") + chatter = ArgsHelper.signal_chatter(args) + + # By default (or if backend == "console"), will read from stdin or a given file and output to console + else: + logging.debug("Console backend selected") + chatter = ConsoleChatter(args.input_file,sys.stdout) + + if args.stealth: + chatter = StealthChatter(chatter) + + return chatter diff --git a/nicobot/helpers.py b/nicobot/helpers.py index 0a3f7f3..c227fb2 100644 --- a/nicobot/helpers.py +++ b/nicobot/helpers.py @@ -4,8 +4,10 @@ Helper functions """ -import sys import logging +import os +import sys +import yaml # Adds a log level finer than DEBUG @@ -56,3 +58,61 @@ def filter_files( files, should_exist=False, fallback_to=None ): return files[fallback_to:fallback_to+1] return found + + +def parse_args_2pass( parser, config ): + + # + # 1st pass only matters for 'bootstrap' options : configuration file and logging + # + # Note : we don't let the parse_args method merge the 'args' into config yet, + # because it would not be possible to make the difference between the default values + # and the ones explictely given by the user + # This is usefull for instance to throw an exception if a file given by the user doesn't exist, which is different than the default filename + # 'config' is therefore the defaults overriden by user options while 'args' has only user options + args = parser.parse_args() + + # Logging configuration + configure_logging(args.verbosity,debug=args.debug) + logging.debug( "Configuration for bootstrap : %s", repr(vars(args)) ) + + # Fills the config with user-defined default options from a config file + try: + # Allows config_file to be relative to the config_dir + config.config_file = filter_files( + [args.config_file, + os.path.join(args.config_dir,"config.yml")], + should_exist=True, + fallback_to=1 )[0] + logging.debug("Using config file %s",config.config_file) + with open(config.config_file,'r') as file: + # The FullLoader parameter handles the conversion from YAML + # scalar values to Python the dictionary format + try: + # This is the required syntax in newer pyyaml distributions + dictConfig = yaml.load(file, Loader=yaml.FullLoader) + except AttributeError: + # Some systems (e.g. raspbian) ship with an older version of pyyaml + dictConfig = yaml.load(file) + logging.debug("Successfully loaded configuration from %s : %s" % (config.config_file,repr(dictConfig))) + config.__dict__.update(dictConfig) + except OSError as e: + # If it was a user-set option, stop here + if args.config_file == config.config_file: + raise e + else: + logging.debug("Could not open %s ; no config file will be used",config.config_file) + logging.debug(e, exc_info=True) + pass + # From here the config object has only the default values for all configuration options + + # + # 2nd pass parses all options + # + # Updates again the existing config object with all parsed options + config = parser.parse_args(namespace=config) + # From the bootstrap parameters, only logging level may need to be read again + configure_logging(config.verbosity,debug=config.debug) + logging.debug( "Final configuration : %s", repr(vars(config)) ) + + return config diff --git a/nicobot/signalcli.py b/nicobot/signalcli.py index 4ee01b4..cc65fef 100755 --- a/nicobot/signalcli.py +++ b/nicobot/signalcli.py @@ -162,7 +162,7 @@ class SignalChatter(Chatter): -class ArgHelper: +class ArgsHelper: """ Command-line parsing helper for Signal-specific options @@ -176,7 +176,7 @@ class ArgHelper: 'signal_stealth': False, }) - def arg_parser(self): + def parser(self): """ Returns a parent parser for Signal-specific arguments """ diff --git a/nicobot/transbot.py b/nicobot/transbot.py index 93e21e4..1e92682 100755 --- a/nicobot/transbot.py +++ b/nicobot/transbot.py @@ -24,8 +24,12 @@ import urllib.request # Own classes from helpers import * from bot import Bot +from bot import ArgsHelper as BotArgsHelper from console import ConsoleChatter +from jabber import JabberChatter +from jabber import arg_parser as jabber_arg_parser from signalcli import SignalChatter +from signalcli import ArgsHelper as SignalArgsHelper from stealth import StealthChatter @@ -534,20 +538,13 @@ if __name__ == '__main__': A convenient CLI to play with this bot """ - # TODO Update with the latest options from askbot (and make the generic ones go into bot.py) - - # - # Two-pass arguments parsing - # - config = Config() - parser = argparse.ArgumentParser( description="A bot that reacts to messages with given keywords by responding with a random translation" ) - # Bootstrap options - parser.add_argument("--config-file", "-c", dest="config_file", help="YAML configuration file.") - parser.add_argument("--config-dir", "-C", dest="config_dir", default=config.config_dir, help="Directory where to find configuration, cache and translation files by default.") - parser.add_argument('--verbosity', '-V', dest='verbosity', default=config.verbosity, help="Log level") - # Core arguments + parser = argparse.ArgumentParser( + parents=[ BotArgsHelper().parser(), jabber_arg_parser(), SignalArgsHelper().parser() ], + description="A bot that reacts to messages with given keywords by responding with a random translation" + ) + # Core arguments for this bot parser.add_argument("--keyword", "-k", dest="keywords", action="append", help="Keyword bot should react to (will write them into the file specified with --keywords-file)") parser.add_argument("--keywords-file", dest="keywords_files", action="append", help="File to load from and write keywords to") parser.add_argument('--locale', '-l', dest='locale', default=config.locale, help="Change default locale (e.g. 'fr_FR')") @@ -556,62 +553,11 @@ if __name__ == '__main__': parser.add_argument("--shutdown", dest="shutdown", help="Shutdown keyword regular expression pattern") parser.add_argument("--ibmcloud-url", dest="ibmcloud_url", help="IBM Cloud API base URL (get it from your resource https://cloud.ibm.com/resources)") parser.add_argument("--ibmcloud-apikey", dest="ibmcloud_apikey", help="IBM Cloud API key (get it from your resource : https://cloud.ibm.com/resources)") - # Chatter-generic arguments - parser.add_argument("--backend", "-b", dest="backend", choices=["signal","console"], default=config.backend, help="Chat backend to use") - parser.add_argument("--input-file", "-i", dest="input_file", default=config.input_file, help="File to read messages from (one per line)") - parser.add_argument('--username', '-U', dest='username', help="Sender's number (e.g. +12345678901 for the 'signal' backend)") - parser.add_argument('--group', '-g', dest='group', help="Group's ID in base64 (e.g. 'mPC9JNVoKDGz0YeZMsbL1Q==' for the 'signal' backend)") - parser.add_argument('--recipient', '-r', dest='recipient', help="Recipient's number (e.g. +12345678901)") - parser.add_argument('--stealth', dest='stealth', action="store_true", default=config.stealth, help="Activate stealth mode on any chosen chatter") - # Signal-specific arguments - parser.add_argument('--signal-cli', dest='signal_cli', default=config.signal_cli, help="Path to `signal-cli` if not in PATH") - parser.add_argument('--signal-stealth', dest='signal_stealth', action="store_true", default=config.signal_stealth, help="Activate Signal chatter's specific stealth mode") # - # 1st pass only matters for 'bootstrap' options : configuration file and logging + # Two-pass arguments parsing # - parser.parse_args(namespace=config) - - # Logging configuration - try: - # Before Python 3.4 and back since 3.4.2 we can simply pass a level name rather than a numeric value (Yes !) - # Otherwise manually parsing textual log levels was not clean IMHO anyway : https://docs.python.org/2/howto/logging.html#logging-to-a-file - logLevel = logging.getLevelName(config.verbosity.upper()) - # Logs are output to stderr ; stdout is reserved to print the answer(s) - logging.basicConfig(level=logLevel, stream=sys.stderr, format='%(asctime)s\t%(levelname)s\t%(message)s') - except ValueError: - raise ValueError('Invalid log level: %s' % config.verbosity) - logging.debug( "Configuration for bootstrap : %s", repr(vars(config)) ) - - # Loads the config file that will be used to lookup some missing parameters - if not config.config_file: - config.config_file = os.path.join(config.config_dir,"config.yml") - logging.debug("Using default config file : %s "%config.config_file) - try: - with open(config.config_file,'r') as file: - # The FullLoader parameter handles the conversion from YAML - # scalar values to Python the dictionary format - try: - # This is the required syntax in newer pyyaml distributions - dictConfig = yaml.load(file, Loader=yaml.FullLoader) - except: - # Some systems (e.g. raspbian) ship with an older version of pyyaml - dictConfig = yaml.load(file) - logging.debug("Successfully loaded configuration from %s : %s" % (config.config_file,repr(dictConfig))) - config.__dict__.update(dictConfig) - except Exception as e: - logging.debug(e, exc_info=True) - pass - # From here the config object has only the default values for all configuration options - #logging.debug( "Configuration after bootstrap : %s", repr(vars(config)) ) - - # - # 2nd pass parses all options - # - # Updates the existing config object with all parsed options - parser.parse_args(namespace=config) - logging.debug( "Final configuration : %s", repr(vars(config)) ) - + config = parse_args_2pass( parser, config ) # # From here the config object has default options from: # 1. hard-coded default values @@ -685,25 +631,7 @@ if __name__ == '__main__': fallback_to=1 )[0] # Creates the chat engine depending on the 'backend' parameter - if config.backend == "signal": - if not config.signal_cli: - raise ValueError("Could not find the 'signal-cli' command in PATH and no --signal-cli given") - if not config.username: - raise ValueError("Missing a username") - if not config.recipient and not config.group: - raise ValueError("Either --recipient or --group must be provided") - chatter = SignalChatter( - username=config.username, - recipient=config.recipient, - group=config.group, - signal_cli=config.signal_cli, - stealth=config.signal_stealth) - # By default (or if backend == "console"), will read from stdin or a given file and output to console - else: - chatter = ConsoleChatter(config.input_file,sys.stdout) - - if config.stealth: - chatter = StealthChatter(chatter) + chatter = BotArgsHelper.chatter(args) # # Real start From 312617df426424eea1e0aaeeb64973c7c1426e87 Mon Sep 17 00:00:00 2001 From: nicobo Date: Fri, 22 May 2020 15:02:04 +0200 Subject: [PATCH 6/7] ~ updated sample conf with latest changes --- test/transbot-sample-conf/config.yml | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/test/transbot-sample-conf/config.yml b/test/transbot-sample-conf/config.yml index 0b88c97..b517c43 100644 --- a/test/transbot-sample-conf/config.yml +++ b/test/transbot-sample-conf/config.yml @@ -11,14 +11,22 @@ ibmcloud_url: https://api.us-south.language-translator.watson.cloud.ibm.com/inst ibmcloud_apikey: "f5sAznhrKQyvBFFaZbtF60m5tzLbqWhyALQawBg5TjRI" backend: console +#backend: jabber #backend: signal # Signal credentials # Make sure to put quotes around the username field as it is a phone number for Signal -username: "+33123456789" -recipient: "+33123456789" +signal_username: "+33123456789" +signal_recipients: + - "+33123456789" # Get this group ID with the command `signal-cli -u +33123456789 listGroups` -#group: "mABCDNVoEFGz0YeZM1234Q==" +#signal_group: "mABCDNVoEFGz0YeZM1234Q==" + +# Used when backend = jabber +jabber_username: mybot@conversations.im +jabber_password: TheBestPasswordInTheWorld +jabber_recipients: + - itsme@conversations.im # Activates stealth mode #stealth: on From 162242bdf94050dadcc81415f03d917fe2b3e61a Mon Sep 17 00:00:00 2001 From: nicobo Date: Fri, 22 May 2020 22:17:17 +0200 Subject: [PATCH 7/7] ~ fixed misnamed argument --- nicobot/transbot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nicobot/transbot.py b/nicobot/transbot.py index 1e92682..00b6752 100755 --- a/nicobot/transbot.py +++ b/nicobot/transbot.py @@ -631,7 +631,7 @@ if __name__ == '__main__': fallback_to=1 )[0] # Creates the chat engine depending on the 'backend' parameter - chatter = BotArgsHelper.chatter(args) + chatter = BotArgsHelper.chatter(config) # # Real start