Merge pull request #27 from nicolabs/jabber

Jabber
This commit is contained in:
nicobo 2020-05-22 23:21:44 +02:00 committed by GitHub
commit fa9d238f63
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 659 additions and 218 deletions

1
.gitignore vendored
View file

@ -131,3 +131,4 @@ dmypy.json
# All local-only / private files
**/local*
**/priv*
**/.omemo

View file

@ -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/).

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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
"""

View file

@ -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

337
nicobot/jabber.py Executable file
View file

@ -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 :
#
# <message xml:lang="en" to="bot9cd51f1a@conversations.im/1655465886336291765177990819" from="bot649ad4ad@conversations.im/Conversations.7Z6J" type="chat" id="26a30844-37d0-4f27-8daa-fd2107f5e706"><archived xmlns="urn:xmpp:mam:tmp" by="bot9cd51f1a@conversations.im" id="1590067423912722"/><stanza-id xmlns="urn:xmpp:sid:0" by="bot9cd51f1a@conversations.im" id="1590067423912722"/>
# <encrypted xmlns="eu.siacs.conversations.axolotl">
# <header sid="307701646">
# <key rid="45781751">MwohBadGcbUIiKsYACIw8UjlZHsOEf79+fjBM44O9bM1YatXKRKaIYuaIkajsedDkIK906Srxk2N5B7jh8EozEAFKpfeqZg//Hqrin6wOGpX+TZ5kUJziXIALUaRs59D3J0=</key>
# <key rid="1929813965">MwohBWTCE+eWLNmdgTy/gyEAAYACIwzz9+B/UFiaCu+5rcHmh3tyQ/GgBhVa+mk81YkQErXpjCpAPyWbKVJn2TH1dXH4Yj7VYYis0HDQ7r28ZDcMMXoxFcp2VNO9l7S23wI=</key>
# <iv>5eM9IHpWSbKfLJj6</iv>
# </header>
# <payload>pX7D+54c</payload>
# </encrypted><request xmlns="urn:xmpp:receipts"/><markable xmlns="urn:xmpp:chat-markers:0"/><origin-id xmlns="urn:xmpp:sid:0" id="26a30844-37d0-4f27-8daa-fd2107f5e706"/><store xmlns="urn:xmpp:hints"/><encryption xmlns="urn:xmpp:eme:0" name="OMEMO" namespace="eu.siacs.conversations.axolotl"/>
# <body>I sent you an OMEMO encrypted message but your client doesnt seem to support that. Find more information on https://conversations.im/omemo</body>
# </message>
# Sample plain message :
# <message xml:lang="en" to="bot9cd51f1a@conversations.im/11834511835037473566179797763" from="bot649ad4ad@conversations.im/Conversations.7Z6J" type="chat" id="5677cbdf-11d2-4aed-889e-0fb3e850a390"><archived xmlns="urn:xmpp:mam:tmp" by="bot9cd51f1a@conversations.im" id="1590095998371956"/><stanza-id xmlns="urn:xmpp:sid:0" by="bot9cd51f1a@conversations.im" id="1590095998371956"/><request xmlns="urn:xmpp:receipts"/><markable xmlns="urn:xmpp:chat-markers:0"/><origin-id xmlns="urn:xmpp:sid:0" id="5677cbdf-11d2-4aed-889e-0fb3e850a390"/><active xmlns="http://jabber.org/protocol/chatstates"/>
# <body>My message</body>
# </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 `<encrypted/>` 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: <Task pending coro=<XMLStream.run_filters() running at /home/./.local/lib/python3.6/site-packages/slixmpp/xmlstream/xmlstream.py:972> wait_for=<Future pending cb=[<TaskWakeupMethWrapper object at 0x7fa91adc0fd8>()]>>
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

View file

@ -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

View file

@ -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
"""

View file

@ -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

View file

@ -12,3 +12,6 @@ requests
emoji-country-flag
# https://pyyaml.org/wiki/PyYAMLDocumentation
pyyaml
##### Requirements for jabber #####
slixmpp-omemo

View file

@ -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

View file

@ -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