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..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...
@@ -200,6 +198,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
+
+- `--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.
+
+
+
## Using the Signal backend
By using `--backend signal` you can make the bot chat with Signal users.
@@ -217,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 :
@@ -230,13 +238,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 +247,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/askbot.py b/nicobot/askbot.py
index b645c81..49ba52f 100644
--- a/nicobot/askbot.py
+++ b/nicobot/askbot.py
@@ -20,8 +20,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
@@ -33,16 +37,11 @@ class Config:
'backend': "console",
'config_file': "config.yml",
'config_dir': os.getcwd(),
- 'group': None,
'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",
})
@@ -128,8 +127,12 @@ class AskBot(Bot):
logging.debug("Bot ready.")
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)
+
# Blocks on this line until the bot exits
logging.debug("Bot reading answer...")
self.chatter.start(self)
@@ -143,98 +146,27 @@ 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)
"""
- #
- # Two-pass arguments parsing
- #
-
- # config is the final, merged configuration
+ # config will be 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=["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', '--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('--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
- parser.add_argument('--password', '-P', dest='password', help="Senders's password")
+ parser = argparse.ArgumentParser(
+ 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
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-stealth', dest='signal_stealth', action="store_true", default=config.signal_stealth, help="Activate Signal chatter's specific stealth mode")
- # Jabber-specific arguments
- # TODO
#
- # 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
@@ -246,27 +178,7 @@ if __name__ == '__main__':
#
# 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[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:
- 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 4b56abf..4d0d085 100644
--- a/nicobot/bot.py
+++ b/nicobot/bot.py
@@ -1,9 +1,18 @@
# -*- coding: utf-8 -*-
+import argparse
import atexit
+import logging
+import os
import signal
import sys
-import logging
+
+
+from console import ConsoleChatter
+from jabber import JabberChatter
+from signalcli import SignalChatter
+from stealth import StealthChatter
+
class Bot:
"""
@@ -46,3 +55,116 @@ class Bot:
Starts the bot
"""
pass
+
+
+
+class ArgsHelper:
+
+ """
+ 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 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
+
+
+ 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/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/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/jabber.py b/nicobot/jabber.py
new file mode 100755
index 0000000..0a43d72
--- /dev/null
+++ b/nicobot/jabber.py
@@ -0,0 +1,337 @@
+# -*- coding: utf-8 -*-
+
+import argparse
+import asyncio
+import logging
+import os
+import time
+
+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
+
+# Own classes
+from chatter import Chatter
+from helpers import *
+
+
+
+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()
+
+
+
+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..cc65fef 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 ArgsHelper:
+
+ """
+ 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 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
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..00b6752 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
@@ -509,6 +513,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 +525,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,18 +538,13 @@ if __name__ == '__main__':
A convenient CLI to play with this bot
"""
- #
- # 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')")
@@ -551,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
@@ -680,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(config)
#
# Real start
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
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
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