summaryrefslogtreecommitdiff
path: root/plugins
diff options
context:
space:
mode:
Diffstat (limited to 'plugins')
-rw-r--r--plugins/autocorrect.py55
-rw-r--r--plugins/close_all.py45
-rw-r--r--plugins/cyber.py40
-rw-r--r--plugins/gpg/__init__.py40
-rw-r--r--plugins/gpg/gnupg.py566
-rw-r--r--plugins/irc.py303
-rw-r--r--plugins/otr.py781
-rw-r--r--plugins/pipe_cmd.py14
-rw-r--r--plugins/reorder.py129
-rw-r--r--plugins/screen_detach.py87
-rw-r--r--plugins/simple_notify.py2
-rw-r--r--plugins/uptime.py2
12 files changed, 1587 insertions, 477 deletions
diff --git a/plugins/autocorrect.py b/plugins/autocorrect.py
index dfd55e6c..a482d47f 100644
--- a/plugins/autocorrect.py
+++ b/plugins/autocorrect.py
@@ -4,15 +4,18 @@ This plugin lets you perform simple replacements on the last message.
Usage
-----
-.. note:: This plugin only performs *simple* replacements, not with
- regular expressions, despite the syntax. Although it would be
- possible, that would be even less useful.
+.. note:: the ``/``, ``#``, ``!``, ``:`` and ``;`` chars can be used as separators,
+ even if the examples only use ``/``
+
+
+Regex replacement
+~~~~~~~~~~~~~~~~~
Once the plugin is loaded, any message matching the following regex::
^s/(.+?)/(.*?)(/|/g)?$
-will be interpreted as a replacement, and the substitution will be
+will be interpreted as a regex replacement, and the substitution will be
applied to the last sent message.
For example, if you sent the message::
@@ -24,12 +27,29 @@ And you now want to replace “MUC” with “multi-user chat”, you input::
s/MUC/multi-user chat
And poezio will correct the message for you.
+
+
+Raw string replacement
+~~~~~~~~~~~~~~~~~~~~~~
+
+Once the plugin is loaded, any message matching the following regex::
+
+ ^r/(.+?)/(.*?)(/|/g)?$
+
+will be interpreted as a replacement, and the substitution will be applied
+to the last send message.
+
+This variant is useful if you don’t want to care about regular expressions
+(and you do not want to have to escape stuff like space or backslashes).
+
+
"""
from plugin import BasePlugin
import re
-sed_re = re.compile('^s/(.+?)/(.*?)(/|/g)?$')
+allowed_separators = '/#!:;'
+sed_re = re.compile('^([sr])(?P<sep>[%s])(.+?)(?P=sep)(.*?)((?P=sep)|(?P=sep)g)?$' % allowed_separators)
class Plugin(BasePlugin):
def init(self):
@@ -46,16 +66,29 @@ class Plugin(BasePlugin):
match = sed_re.match(msg['body'])
if not match:
return
- remove, put, matchall = match.groups()
+ typ, sep, remove, put, matchall = match.groups()
replace_all = False
- if matchall == '/g':
+ if matchall == sep + 'g':
replace_all = True
- if replace_all:
- new_body = body.replace(remove, put)
- else:
- new_body = body.replace(remove, put, 1)
+ if typ == 's':
+ try:
+ regex = re.compile(remove)
+
+ if replace_all:
+ new_body = re.sub(remove, put, body)
+ else:
+ new_body = re.sub(remove, put, body, count=1)
+ except Exception as e:
+ self.api.information('Invalid regex for the autocorrect '
+ 'plugin: %s' % e, 'Error')
+ return
+ elif typ == 'r':
+ if replace_all:
+ new_body = body.replace(remove, put)
+ else:
+ new_body = body.replace(remove, put, 1)
if body != new_body:
msg['body'] = new_body
diff --git a/plugins/close_all.py b/plugins/close_all.py
new file mode 100644
index 00000000..1b98213e
--- /dev/null
+++ b/plugins/close_all.py
@@ -0,0 +1,45 @@
+"""
+``close_all`` plugin: close all tabs except MUCs and the roster.
+
+Commands
+--------
+
+.. glossary::
+
+ /closeall
+ **Usage:** ``/closeall``
+
+ Close all tabs except the roster and MUC tabs.
+"""
+from plugin import BasePlugin
+import tabs
+from decorators import command_args_parser
+
+
+class Plugin(BasePlugin):
+ def init(self):
+ self.api.add_command('closeall', self.command_closeall,
+ help='Close all non-muc tabs.')
+
+ @command_args_parser.ignored
+ def command_closeall(self):
+ """
+ /closeall
+ """
+ current = self.core.current_tab()
+ if not isinstance(current, (tabs.RosterInfoTab, tabs.MucTab)):
+ self.core.go_to_roster()
+ current = self.core.current_tab()
+
+ def filter_func(x):
+ return not isinstance(x, (tabs.RosterInfoTab, tabs.MucTab))
+
+ matching_tabs = list(filter(filter_func, self.core.tabs))
+ length = len(matching_tabs)
+ for tab in matching_tabs:
+ self.core.close_tab(tab)
+ self.core.current_tab_nb = current.nb
+ self.api.information('%s tabs closed.' % length, 'Info')
+ self.core.refresh_window()
+
+
diff --git a/plugins/cyber.py b/plugins/cyber.py
new file mode 100644
index 00000000..67d6cdc7
--- /dev/null
+++ b/plugins/cyber.py
@@ -0,0 +1,40 @@
+"""
+This plugin adds a "cyber" prefix to a random word in your chatroom messages.
+
+Usage
+-----
+
+Say something in a MUC tab.
+
+Configuration options
+---------------------
+
+.. glossary::
+
+ frequency
+ **Default:** ``10``
+
+ The percentage of the time the plugin will activate (randomly). 100 for every message, <= 0 for never.
+"""
+
+from plugin import BasePlugin
+from random import choice, randint
+import re
+
+
+DEFAULT_CONFIG = {'cyber': {'frequency': 10}}
+
+class Plugin(BasePlugin):
+
+ default_config = DEFAULT_CONFIG
+
+ def init(self):
+ self.api.add_event_handler('muc_say', self.cyberize)
+
+ def cyberize(self, msg, tab):
+ if randint(1, 100) > self.config.get('frequency'):
+ return
+ words = [word for word in re.split('\W+', msg['body']) if len(word) > 3]
+ if words:
+ word = choice(words)
+ msg['body'] = msg['body'].replace(word, 'cyber' + word)
diff --git a/plugins/gpg/__init__.py b/plugins/gpg/__init__.py
index bc8a96c1..0f441653 100644
--- a/plugins/gpg/__init__.py
+++ b/plugins/gpg/__init__.py
@@ -117,6 +117,7 @@ log = logging.getLogger(__name__)
from plugin import BasePlugin
from tabs import ConversationTab
+from theming import get_theme
NS_SIGNED = "jabber:x:signed"
NS_ENCRYPTED = "jabber:x:encrypted"
@@ -127,7 +128,6 @@ Hash: %(hash)s
%(clear)s
-----BEGIN PGP SIGNATURE-----
-Version: GnuPG
%(data)s
-----END PGP SIGNATURE-----
@@ -135,7 +135,6 @@ Version: GnuPG
ENCRYPTED_MESSAGE = """-----BEGIN PGP MESSAGE-----
-Version: GnuPG
%(data)s
-----END PGP MESSAGE-----"""
@@ -156,14 +155,14 @@ class Plugin(BasePlugin):
self.keyid = self.config.get('keyid', '') or None
self.passphrase = self.config.get('passphrase', '') or None
if not self.keyid:
- self.core.information('No GPG keyid provided in the configuration', 'Warning')
+ self.api.information('No GPG keyid provided in the configuration', 'Warning')
- self.add_event_handler('send_normal_presence', self.sign_presence)
- self.add_event_handler('normal_presence', self.on_normal_presence)
- self.add_event_handler('conversation_say_after', self.on_conversation_say)
- self.add_event_handler('conversation_msg', self.on_conversation_msg)
+ self.api.add_event_handler('send_normal_presence', self.sign_presence)
+ self.api.add_slix_event_handler('presence', self.on_normal_presence)
+ self.api.add_event_handler('conversation_say_after', self.on_conversation_say)
+ self.api.add_event_handler('conversation_msg', self.on_conversation_msg)
- self.add_tab_command(ConversationTab, 'gpg', self.command_gpg,
+ self.api.add_tab_command(ConversationTab, 'gpg', self.command_gpg,
usage='<force|disable|setkey> [jid] [keyid]',
help='Force or disable gpg encryption with the fulljid of the current conversation. The setkey argument lets you associate a keyid with the given bare JID.',
short='Manage the GPG status',
@@ -197,7 +196,7 @@ class Plugin(BasePlugin):
current_presence = self.core.get_status()
self.core.command_status('%s %s' % (current_presence.show or 'available', current_presence.message or '',))
- def on_normal_presence(self, presence, resource):
+ def on_normal_presence(self, presence):
"""
Check if it’s signed, if it is and we can verify the signature,
add 'valid' or 'invalid' into the dict. If it cannot be verified, just add
@@ -212,7 +211,7 @@ class Plugin(BasePlugin):
return
if self.config.has_section('keys') and bare in self.config.options('keys'):
self.contacts[full] = 'invalid'
- for hash_ in ('SHA1', 'SHA256'):
+ for hash_ in ('SHA1', 'SHA256', 'SHA512'):
to_verify = SIGNED_ATTACHED_MESSAGE % {'clear': presence['status'],
'data': signed.text,
'hash': hash_}
@@ -225,7 +224,7 @@ class Plugin(BasePlugin):
def on_conversation_say(self, message, tab):
"""
- Check if the contact has a signed AND veryfied signature.
+ Check if the contact has a signed AND verified signature.
If yes, encrypt the message with her key.
"""
to = message['to']
@@ -234,12 +233,13 @@ class Plugin(BasePlugin):
return
signed = to.full in self.contacts.keys()
if signed:
- veryfied = self.contacts[to.full] in ('valid', 'forced')
+ verified = self.contacts[to.full] in ('valid', 'forced')
else:
- veryfied = False
- if veryfied:
+ verified = False
+ if verified:
# remove the xhtm_im body if present, because that
# cannot be encrypted.
+ body = message['body']
del message['html']
encrypted_element = ET.Element('{%s}x' % (NS_ENCRYPTED,))
text = self.gpg.encrypt(message['body'], self.config.get(to.bare, '', section='keys'), always_trust=True)
@@ -251,6 +251,13 @@ class Plugin(BasePlugin):
encrypted_element.text = self.remove_gpg_headers(xml.sax.saxutils.escape(str(text)))
message.append(encrypted_element)
message['body'] = 'This message has been encrypted using the GPG key with id: %s' % self.keyid
+ message.send()
+ del message['body']
+ tab.add_message(body, nickname=self.core.own_nick,
+ nick_color=get_theme().COLOR_OWN_NICK,
+ identifier=message['id'],
+ jid=self.core.xmpp.boundjid,
+ typ=0)
def on_conversation_msg(self, message, tab):
"""
@@ -278,7 +285,7 @@ class Plugin(BasePlugin):
if status in ('valid', 'invalid', 'signed'):
return ' GPG Key: %s (%s)' % (status, 'encrypted' if status == 'valid' else 'NOT encrypted',)
else:
- return ' GPG: Encryption %s' % (status,)
+ return ' GPG: Encryption %s' % (status,)
def command_gpg(self, args):
"""
@@ -314,7 +321,8 @@ class Plugin(BasePlugin):
self.core.refresh_window()
def gpg_completion(self, the_input):
- return the_input.auto_completion(['force', 'disable', 'setkey'], ' ')
+ if the_input.get_argument_position() == 1:
+ return the_input.new_completion(['force', 'disable', 'setkey'], 1, quotify=False)
def remove_gpg_headers(self, text):
lines = text.splitlines()
diff --git a/plugins/gpg/gnupg.py b/plugins/gpg/gnupg.py
index 5cb11766..99bd7d25 100644
--- a/plugins/gpg/gnupg.py
+++ b/plugins/gpg/gnupg.py
@@ -27,15 +27,14 @@ Vinay Sajip to make use of the subprocess module (Steve's version uses os.fork()
and so does not work on Windows). Renamed to gnupg.py to avoid confusion with
the previous versions.
-Modifications Copyright (C) 2008-2012 Vinay Sajip. All rights reserved.
+Modifications Copyright (C) 2008-2014 Vinay Sajip. All rights reserved.
A unittest harness (test_gnupg.py) has also been added.
"""
-import locale
-__version__ = "0.3.1"
+__version__ = "0.3.8.dev0"
__author__ = "Vinay Sajip"
-__date__ = "$01-Sep-2012 20:02:51$"
+__date__ = "$07-Dec-2014 18:46:17$"
try:
from io import StringIO
@@ -46,6 +45,7 @@ import codecs
import locale
import logging
import os
+import re
import socket
from subprocess import Popen
from subprocess import PIPE
@@ -61,13 +61,61 @@ except ImportError:
try:
unicode
_py3k = False
+ string_types = basestring
+ text_type = unicode
except NameError:
_py3k = True
+ string_types = str
+ text_type = str
logger = logging.getLogger(__name__)
if not logger.handlers:
logger.addHandler(NullHandler())
+# We use the test below because it works for Jython as well as CPython
+if os.path.__name__ == 'ntpath':
+ # On Windows, we don't need shell quoting, other than worrying about
+ # paths with spaces in them.
+ def shell_quote(s):
+ return '"%s"' % s
+else:
+ # Section copied from sarge
+
+ # This regex determines which shell input needs quoting
+ # because it may be unsafe
+ UNSAFE = re.compile(r'[^\w%+,./:=@-]')
+
+ def shell_quote(s):
+ """
+ Quote text so that it is safe for Posix command shells.
+
+ For example, "*.py" would be converted to "'*.py'". If the text is
+ considered safe it is returned unquoted.
+
+ :param s: The value to quote
+ :type s: str (or unicode on 2.x)
+ :return: A safe version of the input, from the point of view of Posix
+ command shells
+ :rtype: The passed-in type
+ """
+ if not isinstance(s, string_types):
+ raise TypeError('Expected string type, got %s' % type(s))
+ if not s:
+ result = "''"
+ elif not UNSAFE.search(s):
+ result = s
+ else:
+ result = "'%s'" % s.replace("'", r"'\''")
+ return result
+
+ # end of sarge code
+
+# Now that we use shell=False, we shouldn't need to quote arguments.
+# Use no_quote instead of shell_quote to remind us of where quoting
+# was needed.
+def no_quote(s):
+ return s
+
def _copy_data(instream, outstream):
# Copy one stream to another
sent = 0
@@ -77,7 +125,7 @@ def _copy_data(instream, outstream):
enc = 'ascii'
while True:
data = instream.read(1024)
- if len(data) == 0:
+ if not data:
break
sent += len(data)
logger.debug("sending chunk (%d): %r", sent, data[:256])
@@ -107,25 +155,28 @@ def _write_passphrase(stream, passphrase, encoding):
passphrase = '%s\n' % passphrase
passphrase = passphrase.encode(encoding)
stream.write(passphrase)
- logger.debug("Wrote passphrase: %r", passphrase)
+ logger.debug('Wrote passphrase')
def _is_sequence(instance):
- return isinstance(instance,list) or isinstance(instance,tuple)
+ return isinstance(instance, (list, tuple, set, frozenset))
-def _make_binary_stream(s, encoding):
+def _make_memory_stream(s):
try:
- if _py3k:
- if isinstance(s, str):
- s = s.encode(encoding)
- else:
- if type(s) is not str:
- s = s.encode(encoding)
from io import BytesIO
rv = BytesIO(s)
except ImportError:
rv = StringIO(s)
return rv
+def _make_binary_stream(s, encoding):
+ if _py3k:
+ if isinstance(s, str):
+ s = s.encode(encoding)
+ else:
+ if type(s) is not str:
+ s = s.encode(encoding)
+ return _make_memory_stream(s)
+
class Verify(object):
"Handle status messages for --verify"
@@ -149,6 +200,7 @@ class Verify(object):
self.fingerprint = self.creation_date = self.timestamp = None
self.signature_id = self.key_id = None
self.username = None
+ self.key_status = None
self.status = None
self.pubkey_fingerprint = None
self.expire_timestamp = None
@@ -166,13 +218,27 @@ class Verify(object):
self.trust_text = key
self.trust_level = self.TRUST_LEVELS[key]
elif key in ("RSA_OR_IDEA", "NODATA", "IMPORT_RES", "PLAINTEXT",
- "PLAINTEXT_LENGTH", "POLICY_URL", "DECRYPTION_INFO",
- "DECRYPTION_OKAY"):
+ "PLAINTEXT_LENGTH", "POLICY_URL", "DECRYPTION_INFO",
+ "DECRYPTION_OKAY", "INV_SGNR", "FILE_START", "FILE_ERROR",
+ "FILE_DONE", "PKA_TRUST_GOOD", "PKA_TRUST_BAD", "BADMDC",
+ "GOODMDC", "NO_SGNR", "NOTATION_NAME", "NOTATION_DATA",
+ "PROGRESS"):
pass
elif key == "BADSIG":
self.valid = False
self.status = 'signature bad'
self.key_id, self.username = value.split(None, 1)
+ elif key == "ERRSIG":
+ self.valid = False
+ (self.key_id,
+ algo, hash_algo,
+ cls,
+ self.timestamp) = value.split()[:5]
+ self.status = 'signature error'
+ elif key == "EXPSIG":
+ self.valid = False
+ self.status = 'signature expired'
+ self.key_id, self.username = value.split(None, 1)
elif key == "GOODSIG":
self.valid = True
self.status = 'signature good'
@@ -188,13 +254,6 @@ class Verify(object):
elif key == "SIG_ID":
(self.signature_id,
self.creation_date, self.timestamp) = value.split()
- elif key == "ERRSIG":
- self.valid = False
- (self.key_id,
- algo, hash_algo,
- cls,
- self.timestamp) = value.split()[:5]
- self.status = 'signature error'
elif key == "DECRYPTION_FAILED":
self.valid = False
self.key_id = value
@@ -203,17 +262,25 @@ class Verify(object):
self.valid = False
self.key_id = value
self.status = 'no public key'
- elif key in ("KEYEXPIRED", "SIGEXPIRED"):
+ elif key in ("KEYEXPIRED", "SIGEXPIRED", "KEYREVOKED"):
# these are useless in verify, since they are spit out for any
# pub/subkeys on the key, not just the one doing the signing.
# if we want to check for signatures with expired key,
- # the relevant flag is EXPKEYSIG.
+ # the relevant flag is EXPKEYSIG or REVKEYSIG.
pass
elif key in ("EXPKEYSIG", "REVKEYSIG"):
# signed with expired or revoked key
self.valid = False
self.key_id = value.split()[0]
- self.status = (('%s %s') % (key[:3], key[3:])).lower()
+ if key == "EXPKEYSIG":
+ self.key_status = 'signing key has expired'
+ else:
+ self.key_status = 'signing key was revoked'
+ self.status = self.key_status
+ elif key == "UNEXPECTED":
+ self.valid = False
+ self.key_id = value
+ self.status = 'unexpected data'
else:
raise ValueError("Unknown status message: %r" % key)
@@ -282,8 +349,8 @@ class ImportResult(object):
'problem': reason, 'text': self.problem_reason[reason]})
elif key == "IMPORT_RES":
import_res = value.split()
- for i in range(len(self.counts)):
- setattr(self, self.counts[i], int(import_res[i]))
+ for i, count in enumerate(self.counts):
+ setattr(self, count, int(import_res[i]))
elif key == "KEYEXPIRED":
self.results.append({'fingerprint': None,
'problem': '0', 'text': 'Key expired'})
@@ -300,7 +367,63 @@ class ImportResult(object):
l.append('%d not imported' % self.not_imported)
return ', '.join(l)
-class ListKeys(list):
+ESCAPE_PATTERN = re.compile(r'\\x([0-9a-f][0-9a-f])', re.I)
+BASIC_ESCAPES = {
+ r'\n': '\n',
+ r'\r': '\r',
+ r'\f': '\f',
+ r'\v': '\v',
+ r'\b': '\b',
+ r'\0': '\0',
+}
+
+class SendResult(object):
+ def __init__(self, gpg):
+ self.gpg = gpg
+
+ def handle_status(self, key, value):
+ logger.debug('SendResult: %s: %s', key, value)
+
+class SearchKeys(list):
+ ''' Handle status messages for --search-keys.
+
+ Handle pub and uid (relating the latter to the former).
+
+ Don't care about the rest
+ '''
+
+ UID_INDEX = 1
+ FIELDS = 'type keyid algo length date expires'.split()
+
+ def __init__(self, gpg):
+ self.gpg = gpg
+ self.curkey = None
+ self.fingerprints = []
+ self.uids = []
+
+ def get_fields(self, args):
+ result = {}
+ for i, var in enumerate(self.FIELDS):
+ result[var] = args[i]
+ result['uids'] = []
+ return result
+
+ def pub(self, args):
+ self.curkey = curkey = self.get_fields(args)
+ self.append(curkey)
+
+ def uid(self, args):
+ uid = args[self.UID_INDEX]
+ uid = ESCAPE_PATTERN.sub(lambda m: chr(int(m.group(1), 16)), uid)
+ for k, v in BASIC_ESCAPES.items():
+ uid = uid.replace(k, v)
+ self.curkey['uids'].append(uid)
+ self.uids.append(uid)
+
+ def handle_status(self, key, value):
+ pass
+
+class ListKeys(SearchKeys):
''' Handle status messages for --list-keys.
Handle pub and uid (relating the latter to the former).
@@ -317,25 +440,17 @@ class ListKeys(list):
grp = reserved for gpgsm
rvk = revocation key
'''
- def __init__(self, gpg):
- self.gpg = gpg
- self.curkey = None
- self.fingerprints = []
- self.uids = []
+
+ UID_INDEX = 9
+ FIELDS = 'type trust length algo keyid date expires dummy ownertrust uid'.split()
def key(self, args):
- vars = ("""
- type trust length algo keyid date expires dummy ownertrust uid
- """).split()
- self.curkey = {}
- for i in range(len(vars)):
- self.curkey[vars[i]] = args[i]
- self.curkey['uids'] = []
- if self.curkey['uid']:
- self.curkey['uids'].append(self.curkey['uid'])
- del self.curkey['uid']
- self.curkey['subkeys'] = []
- self.append(self.curkey)
+ self.curkey = curkey = self.get_fields(args)
+ if curkey['uid']:
+ curkey['uids'].append(curkey['uid'])
+ del curkey['uid']
+ curkey['subkeys'] = []
+ self.append(curkey)
pub = sec = key
@@ -343,18 +458,34 @@ class ListKeys(list):
self.curkey['fingerprint'] = args[9]
self.fingerprints.append(args[9])
- def uid(self, args):
- self.curkey['uids'].append(args[9])
- self.uids.append(args[9])
-
def sub(self, args):
subkey = [args[4], args[11]]
self.curkey['subkeys'].append(subkey)
- def handle_status(self, key, value):
- pass
-class Crypt(Verify):
+class ScanKeys(ListKeys):
+ ''' Handle status messages for --with-fingerprint.'''
+
+ def sub(self, args):
+ # --with-fingerprint --with-colons somehow outputs fewer colons,
+ # use the last value args[-1] instead of args[11]
+ subkey = [args[4], args[-1]]
+ self.curkey['subkeys'].append(subkey)
+
+class TextHandler(object):
+ def _as_text(self):
+ return self.data.decode(self.gpg.encoding, self.gpg.decode_errors)
+
+ if _py3k:
+ __str__ = _as_text
+ else:
+ __unicode__ = _as_text
+
+ def __str__(self):
+ return self.data
+
+
+class Crypt(Verify, TextHandler):
"Handle status messages for --encrypt and --decrypt"
def __init__(self, gpg):
Verify.__init__(self, gpg)
@@ -368,19 +499,16 @@ class Crypt(Verify):
__bool__ = __nonzero__
- def __str__(self):
- return self.data.decode(self.gpg.encoding, self.gpg.decode_errors)
-
def handle_status(self, key, value):
if key in ("ENC_TO", "USERID_HINT", "GOODMDC", "END_DECRYPTION",
- "BEGIN_SIGNING", "NO_SECKEY", "ERROR", "NODATA",
- "CARDCTRL"):
+ "BEGIN_SIGNING", "NO_SECKEY", "ERROR", "NODATA", "PROGRESS",
+ "CARDCTRL", "BADMDC", "SC_OP_FAILURE", "SC_OP_SUCCESS"):
# in the case of ERROR, this is because a more specific error
# message will have come first
pass
elif key in ("NEED_PASSPHRASE", "BAD_PASSPHRASE", "GOOD_PASSPHRASE",
"MISSING_PASSPHRASE", "DECRYPTION_FAILED",
- "KEY_NOT_CREATED"):
+ "KEY_NOT_CREATED", "NEED_PASSPHRASE_PIN"):
self.status = key.replace("_", " ").lower()
elif key == "NEED_PASSPHRASE_SYM":
self.status = 'need symmetric passphrase'
@@ -441,7 +569,7 @@ class DeleteResult(object):
problem_reason = {
'1': 'No such key',
'2': 'Must delete secret key first',
- '3': 'Ambigious specification',
+ '3': 'Ambiguous specification',
}
def handle_status(self, key, value):
@@ -451,11 +579,18 @@ class DeleteResult(object):
else:
raise ValueError("Unknown status message: %r" % key)
-class Sign(object):
+ def __nonzero__(self):
+ return self.status == 'ok'
+
+ __bool__ = __nonzero__
+
+
+class Sign(TextHandler):
"Handle status messages for --sign"
def __init__(self, gpg):
self.gpg = gpg
self.type = None
+ self.hash_algo = None
self.fingerprint = None
def __nonzero__(self):
@@ -463,21 +598,26 @@ class Sign(object):
__bool__ = __nonzero__
- def __str__(self):
- return self.data.decode(self.gpg.encoding, self.gpg.decode_errors)
-
def handle_status(self, key, value):
if key in ("USERID_HINT", "NEED_PASSPHRASE", "BAD_PASSPHRASE",
- "GOOD_PASSPHRASE", "BEGIN_SIGNING", "CARDCTRL", "INV_SGNR"):
+ "GOOD_PASSPHRASE", "BEGIN_SIGNING", "CARDCTRL", "INV_SGNR",
+ "NO_SGNR", "MISSING_PASSPHRASE", "NEED_PASSPHRASE_PIN",
+ "SC_OP_FAILURE", "SC_OP_SUCCESS", "PROGRESS"):
pass
+ elif key in ("KEYEXPIRED", "SIGEXPIRED"):
+ self.status = 'key expired'
+ elif key == "KEYREVOKED":
+ self.status = 'key revoked'
elif key == "SIG_CREATED":
(self.type,
- algo, hashalgo, cls,
+ algo, self.hash_algo, cls,
self.timestamp, self.fingerprint
) = value.split()
else:
raise ValueError("Unknown status message: %r" % key)
+VERSION_RE = re.compile(r'gpg \(GnuPG\) (\d+(\.\d+)*)'.encode('ascii'), re.I)
+HEX_DIGITS_RE = re.compile(r'[0-9a-f]+$', re.I)
class GPG(object):
@@ -488,35 +628,54 @@ class GPG(object):
'delete': DeleteResult,
'generate': GenKey,
'import': ImportResult,
+ 'send': SendResult,
'list': ListKeys,
+ 'scan': ScanKeys,
+ 'search': SearchKeys,
'sign': Sign,
'verify': Verify,
}
"Encapsulate access to the gpg executable"
def __init__(self, gpgbinary='gpg', gnupghome=None, verbose=False,
- use_agent=False, keyring=None, options=None):
+ use_agent=False, keyring=None, options=None,
+ secret_keyring=None):
"""Initialize a GPG process wrapper. Options are:
gpgbinary -- full pathname for GPG binary.
gnupghome -- full pathname to where we can find the public and
private keyrings. Default is whatever gpg defaults to.
- keyring -- name of alternative keyring file to use. If specified,
- the default keyring is not used.
+ keyring -- name of alternative keyring file to use, or list of such
+ keyrings. If specified, the default keyring is not used.
options =-- a list of additional options to pass to the GPG binary.
+ secret_keyring -- name of alternative secret keyring file to use, or
+ list of such keyrings.
"""
self.gpgbinary = gpgbinary
self.gnupghome = gnupghome
+ if keyring:
+ # Allow passing a string or another iterable. Make it uniformly
+ # a list of keyring filenames
+ if isinstance(keyring, string_types):
+ keyring = [keyring]
self.keyring = keyring
+ if secret_keyring:
+ # Allow passing a string or another iterable. Make it uniformly
+ # a list of keyring filenames
+ if isinstance(secret_keyring, string_types):
+ secret_keyring = [secret_keyring]
+ self.secret_keyring = secret_keyring
self.verbose = verbose
self.use_agent = use_agent
if isinstance(options, str):
options = [options]
self.options = options
- self.encoding = locale.getpreferredencoding()
- if self.encoding is None: # This happens on Jython!
- self.encoding = sys.stdin.encoding
+ # Changed in 0.3.7 to use Latin-1 encoding rather than
+ # locale.getpreferredencoding falling back to sys.stdin.encoding
+ # falling back to utf-8, because gpg itself uses latin-1 as the default
+ # encoding.
+ self.encoding = 'latin-1'
if gnupghome and not os.path.isdir(self.gnupghome):
os.makedirs(self.gnupghome,0x1C0)
p = self._open_subprocess(["--version"])
@@ -525,6 +684,12 @@ class GPG(object):
if p.returncode != 0:
raise ValueError("Error invoking gpg: %s: %s" % (p.returncode,
result.stderr))
+ m = VERSION_RE.match(result.data)
+ if not m:
+ self.version = None
+ else:
+ dot = '.'.encode('ascii')
+ self.version = tuple([int(s) for s in m.groups()[0].split(dot)])
def make_args(self, args, passphrase):
"""
@@ -532,13 +697,18 @@ class GPG(object):
will be appended. The ``passphrase`` argument needs to be True if
a passphrase will be sent to GPG, else False.
"""
- cmd = [self.gpgbinary, '--status-fd 2 --no-tty']
+ cmd = [self.gpgbinary, '--status-fd', '2', '--no-tty']
if self.gnupghome:
- cmd.append('--homedir "%s" ' % self.gnupghome)
+ cmd.extend(['--homedir', no_quote(self.gnupghome)])
if self.keyring:
- cmd.append('--no-default-keyring --keyring "%s" ' % self.keyring)
+ cmd.append('--no-default-keyring')
+ for fn in self.keyring:
+ cmd.extend(['--keyring', no_quote(fn)])
+ if self.secret_keyring:
+ for fn in self.secret_keyring:
+ cmd.extend(['--secret-keyring', no_quote(fn)])
if passphrase:
- cmd.append('--batch --passphrase-fd 0')
+ cmd.extend(['--batch', '--passphrase-fd', '0'])
if self.use_agent:
cmd.append('--use-agent')
if self.options:
@@ -549,11 +719,12 @@ class GPG(object):
def _open_subprocess(self, args, passphrase=False):
# Internal method: open a pipe to a GPG subprocess and return
# the file objects for communicating with it.
- cmd = ' '.join(self.make_args(args, passphrase))
+ cmd = self.make_args(args, passphrase)
if self.verbose:
- print(cmd)
+ pcmd = ' '.join(cmd)
+ print(pcmd)
logger.debug("%s", cmd)
- return Popen(cmd, shell=True, stdin=PIPE, stdout=PIPE, stderr=PIPE)
+ return Popen(cmd, shell=False, stdin=PIPE, stdout=PIPE, stderr=PIPE)
def _read_response(self, stream, result):
# Internal method: reads all the stderr output from GPG, taking notice
@@ -561,31 +732,27 @@ class GPG(object):
#
# Calls methods on the response object for each valid token found,
# with the arg being the remainder of the status line.
- try:
- lines = []
- while True:
- line = stream.readline()
- if len(line) == 0:
- break
- lines.append(line)
- line = line.rstrip()
- if self.verbose:
- print(line)
- logger.debug("%s", line)
- if line[0:9] == '[GNUPG:] ':
- # Chop off the prefix
- line = line[9:]
- L = line.split(None, 1)
- keyword = L[0]
- if len(L) > 1:
- value = L[1]
- else:
- value = ""
- result.handle_status(keyword, value)
- result.stderr = ''.join(lines)
- except:
- import traceback
- logger.error('Error in the GPG plugin:\n%s', traceback.format_exc())
+ lines = []
+ while True:
+ line = stream.readline()
+ if len(line) == 0:
+ break
+ lines.append(line)
+ line = line.rstrip()
+ if self.verbose:
+ print(line)
+ logger.debug("%s", line)
+ if line[0:9] == '[GNUPG:] ':
+ # Chop off the prefix
+ line = line[9:]
+ L = line.split(None, 1)
+ keyword = L[0]
+ if len(L) > 1:
+ value = L[1]
+ else:
+ value = ""
+ result.handle_status(keyword, value)
+ result.stderr = ''.join(lines)
def _read_data(self, stream, result):
# Read the contents of the file from GPG's stdout
@@ -634,7 +801,7 @@ class GPG(object):
stderr.close()
stdout.close()
- def _handle_io(self, args, file, result, passphrase=None, binary=False):
+ def _handle_io(self, args, fileobj, result, passphrase=None, binary=False):
"Handle a call to GPG - pass input data, collect output data"
# Handle a basic data call - pass data to GPG, handle the output
# including status information. Garbage In, Garbage Out :)
@@ -645,7 +812,7 @@ class GPG(object):
stdin = p.stdin
if passphrase:
_write_passphrase(stdin, passphrase, self.encoding)
- writer = _threaded_copy_data(file, stdin)
+ writer = _threaded_copy_data(fileobj, stdin)
self._collect_output(p, result, writer, stdin)
return result
@@ -659,8 +826,15 @@ class GPG(object):
f.close()
return result
+ def set_output_without_confirmation(self, args, output):
+ "If writing to a file which exists, avoid a confirmation message."
+ if os.path.exists(output):
+ # We need to avoid an overwrite confirmation message
+ args.extend(['--batch', '--yes'])
+ args.extend(['--output', output])
+
def sign_file(self, file, keyid=None, passphrase=None, clearsign=True,
- detach=False, binary=False):
+ detach=False, binary=False, output=None):
"""sign file"""
logger.debug("sign_file: %s", file)
if binary:
@@ -674,7 +848,10 @@ class GPG(object):
elif clearsign:
args.append("--clearsign")
if keyid:
- args.append('--default-key "%s"' % keyid)
+ args.extend(['--default-key', no_quote(keyid)])
+ if output: # write the output to a file with the specified name
+ self.set_output_without_confirmation(args, output)
+
result = self.result_map['sign'](self)
#We could use _handle_io here except for the fact that if the
#passphrase is bad, gpg bails and you can't write the message.
@@ -726,8 +903,8 @@ class GPG(object):
logger.debug('Wrote to temp file: %r', s)
os.write(fd, s)
os.close(fd)
- args.append(fn)
- args.append('"%s"' % data_filename)
+ args.append(no_quote(fn))
+ args.append(no_quote(data_filename))
try:
p = self._open_subprocess(args)
self._collect_output(p, result, stdin=p.stdin)
@@ -735,6 +912,15 @@ class GPG(object):
os.unlink(fn)
return result
+ def verify_data(self, sig_filename, data):
+ "Verify the signature in sig_filename against data in memory"
+ logger.debug('verify_data: %r, %r ...', sig_filename, data[:16])
+ result = self.result_map['verify'](self)
+ args = ['--verify', no_quote(sig_filename), '-']
+ stream = _make_memory_stream(data)
+ self._handle_io(args, stream, result, binary=True)
+ return result
+
#
# KEY MANAGEMENT
#
@@ -798,7 +984,8 @@ class GPG(object):
>>> import shutil
>>> shutil.rmtree("keys")
>>> gpg = GPG(gnupghome="keys")
- >>> result = gpg.recv_keys('pgp.mit.edu', '3FF0DB166A7476EA')
+ >>> os.chmod('keys', 0x1C0)
+ >>> result = gpg.recv_keys('keyserver.ubuntu.com', '92905378')
>>> assert result
"""
@@ -806,33 +993,60 @@ class GPG(object):
logger.debug('recv_keys: %r', keyids)
data = _make_binary_stream("", self.encoding)
#data = ""
- args = ['--keyserver', keyserver, '--recv-keys']
- args.extend(keyids)
+ args = ['--keyserver', no_quote(keyserver), '--recv-keys']
+ args.extend([no_quote(k) for k in keyids])
self._handle_io(args, data, result, binary=True)
logger.debug('recv_keys result: %r', result.__dict__)
data.close()
return result
+ def send_keys(self, keyserver, *keyids):
+ """Send a key to a keyserver.
+
+ Note: it's not practical to test this function without sending
+ arbitrary data to live keyservers.
+ """
+ result = self.result_map['send'](self)
+ logger.debug('send_keys: %r', keyids)
+ data = _make_binary_stream('', self.encoding)
+ #data = ""
+ args = ['--keyserver', no_quote(keyserver), '--send-keys']
+ args.extend([no_quote(k) for k in keyids])
+ self._handle_io(args, data, result, binary=True)
+ logger.debug('send_keys result: %r', result.__dict__)
+ data.close()
+ return result
+
def delete_keys(self, fingerprints, secret=False):
which='key'
if secret:
which='secret-key'
if _is_sequence(fingerprints):
- fingerprints = ' '.join(fingerprints)
- args = ['--batch --delete-%s "%s"' % (which, fingerprints)]
+ fingerprints = [no_quote(s) for s in fingerprints]
+ else:
+ fingerprints = [no_quote(fingerprints)]
+ args = ['--batch', '--delete-%s' % which]
+ args.extend(fingerprints)
result = self.result_map['delete'](self)
p = self._open_subprocess(args)
self._collect_output(p, result, stdin=p.stdin)
return result
- def export_keys(self, keyids, secret=False):
+ def export_keys(self, keyids, secret=False, armor=True, minimal=False):
"export the indicated keys. 'keyid' is anything gpg accepts"
which=''
if secret:
which='-secret-key'
if _is_sequence(keyids):
- keyids = ' '.join(['"%s"' % k for k in keyids])
- args = ["--armor --export%s %s" % (which, keyids)]
+ keyids = [no_quote(k) for k in keyids]
+ else:
+ keyids = [no_quote(keyids)]
+ args = ['--export%s' % which]
+ if armor:
+ args.append('--armor')
+ if minimal:
+ args.extend(['--export-options','export-minimal'])
+ args.extend(keyids)
p = self._open_subprocess(args)
# gpg --export produces no status-fd output; stdout will be
# empty in case of failure
@@ -842,6 +1056,27 @@ class GPG(object):
logger.debug('export_keys result: %r', result.data)
return result.data.decode(self.encoding, self.decode_errors)
+ def _get_list_output(self, p, kind):
+ # Get the response information
+ result = self.result_map[kind](self)
+ self._collect_output(p, result, stdin=p.stdin)
+ lines = result.data.decode(self.encoding,
+ self.decode_errors).splitlines()
+ valid_keywords = 'pub uid sec fpr sub'.split()
+ for line in lines:
+ if self.verbose:
+ print(line)
+ logger.debug("line: %r", line.rstrip())
+ if not line:
+ break
+ L = line.strip().split(':')
+ if not L:
+ continue
+ keyword = L[0]
+ if keyword in valid_keywords:
+ getattr(result, keyword)(L)
+ return result
+
def list_keys(self, secret=False):
""" list the keys currently in the keyring
@@ -862,25 +1097,58 @@ class GPG(object):
which='keys'
if secret:
which='secret-keys'
- args = "--list-%s --fixed-list-mode --fingerprint --with-colons" % (which,)
- args = [args]
+ args = ["--list-%s" % which, "--fixed-list-mode", "--fingerprint",
+ "--with-colons"]
p = self._open_subprocess(args)
+ return self._get_list_output(p, 'list')
- # there might be some status thingumy here I should handle... (amk)
- # ...nope, unless you care about expired sigs or keys (stevegt)
+ def scan_keys(self, filename):
+ """
+ List details of an ascii armored or binary key file
+ without first importing it to the local keyring.
+
+ The function achieves this by running:
+ $ gpg --with-fingerprint --with-colons filename
+ """
+ args = ['--with-fingerprint', '--with-colons']
+ args.append(no_quote(filename))
+ p = self._open_subprocess(args)
+ return self._get_list_output(p, 'scan')
+
+ def search_keys(self, query, keyserver='pgp.mit.edu'):
+ """ search keyserver by query (using --search-keys option)
+
+ >>> import shutil
+ >>> shutil.rmtree('keys')
+ >>> gpg = GPG(gnupghome='keys')
+ >>> os.chmod('keys', 0x1C0)
+ >>> result = gpg.search_keys('<vinay_sajip@hotmail.com>')
+ >>> assert result, 'Failed using default keyserver'
+ >>> keyserver = 'keyserver.ubuntu.com'
+ >>> result = gpg.search_keys('<vinay_sajip@hotmail.com>', keyserver)
+ >>> assert result, 'Failed using keyserver.ubuntu.com'
+
+ """
+ query = query.strip()
+ if HEX_DIGITS_RE.match(query):
+ query = '0x' + query
+ args = ['--fixed-list-mode', '--fingerprint', '--with-colons',
+ '--keyserver', no_quote(keyserver), '--search-keys',
+ no_quote(query)]
+ p = self._open_subprocess(args)
# Get the response information
- result = self.result_map['list'](self)
+ result = self.result_map['search'](self)
self._collect_output(p, result, stdin=p.stdin)
lines = result.data.decode(self.encoding,
self.decode_errors).splitlines()
- valid_keywords = 'pub uid sec fpr sub'.split()
+ valid_keywords = ['pub', 'uid']
for line in lines:
if self.verbose:
print(line)
- logger.debug("line: %r", line.rstrip())
- if not line:
- break
+ logger.debug('line: %r', line.rstrip())
+ if not line: # sometimes get blank lines on Windows
+ continue
L = line.strip().split(':')
if not L:
continue
@@ -901,7 +1169,7 @@ class GPG(object):
>>> assert not result
"""
- args = ["--gen-key --batch"]
+ args = ["--gen-key", "--batch"]
result = self.result_map['generate'](self)
f = _make_binary_stream(input, self.encoding)
self._handle_io(args, f, result, binary=True)
@@ -915,11 +1183,11 @@ class GPG(object):
parms = {}
for key, val in list(kwargs.items()):
key = key.replace('_','-').title()
- parms[key] = val
+ if str(val).strip(): # skip empty strings
+ parms[key] = val
parms.setdefault('Key-Type','RSA')
- parms.setdefault('Key-Length',1024)
+ parms.setdefault('Key-Length',2048)
parms.setdefault('Name-Real', "Autogenerated Key")
- parms.setdefault('Name-Comment', "Generated by gnupg.py")
try:
logname = os.environ['LOGNAME']
except KeyError:
@@ -964,23 +1232,30 @@ class GPG(object):
"Encrypt the message read from the file-like object 'file'"
args = ['--encrypt']
if symmetric:
+ # can't be False or None - could be True or a cipher algo value
+ # such as AES256
args = ['--symmetric']
+ if symmetric is not True:
+ args.extend(['--cipher-algo', no_quote(symmetric)])
+ # else use the default, currently CAST5
else:
- args = ['--encrypt']
+ if not recipients:
+ raise ValueError('No recipients specified with asymmetric '
+ 'encryption')
if not _is_sequence(recipients):
recipients = (recipients,)
for recipient in recipients:
- args.append('--recipient "%s"' % recipient)
- if armor: # create ascii-armored output - set to False for binary output
+ args.extend(['--recipient', no_quote(recipient)])
+ if armor: # create ascii-armored output - False for binary output
args.append('--armor')
if output: # write the output to a file with the specified name
- if os.path.exists(output):
- os.remove(output) # to avoid overwrite confirmation message
- args.append('--output "%s"' % output)
- if sign:
- args.append('--sign --default-key "%s"' % sign)
+ self.set_output_without_confirmation(args, output)
+ if sign is True:
+ args.append('--sign')
+ elif sign:
+ args.extend(['--sign', '--default-key', no_quote(sign)])
if always_trust:
- args.append("--always-trust")
+ args.append('--always-trust')
result = self.result_map['crypt'](self)
self._handle_io(args, file, result, passphrase=passphrase, binary=True)
logger.debug('encrypt result: %r', result.data)
@@ -1008,9 +1283,6 @@ class GPG(object):
'hello'
>>> result = gpg.encrypt("hello again",print1)
>>> message = str(result)
- >>> result = gpg.decrypt(message)
- >>> result.status == 'need passphrase'
- True
>>> result = gpg.decrypt(message,passphrase='bar')
>>> result.status in ('decryption failed', 'bad passphrase')
True
@@ -1020,9 +1292,6 @@ class GPG(object):
True
>>> str(result)
'hello again'
- >>> result = gpg.encrypt("signed hello",print2,sign=print1)
- >>> result.status == 'need passphrase'
- True
>>> result = gpg.encrypt("signed hello",print2,sign=print1,passphrase='foo')
>>> result.status == 'encryption ok'
True
@@ -1048,13 +1317,10 @@ class GPG(object):
output=None):
args = ["--decrypt"]
if output: # write the output to a file with the specified name
- if os.path.exists(output):
- os.remove(output) # to avoid overwrite confirmation message
- args.append('--output "%s"' % output)
+ self.set_output_without_confirmation(args, output)
if always_trust:
args.append("--always-trust")
result = self.result_map['crypt'](self)
self._handle_io(args, file, result, passphrase, binary=True)
logger.debug('decrypt result: %r', result.data)
return result
-
diff --git a/plugins/irc.py b/plugins/irc.py
index 6341851e..065b1e62 100644
--- a/plugins/irc.py
+++ b/plugins/irc.py
@@ -4,8 +4,9 @@ Plugin destined to be used together with the Biboumi IRC gateway.
For more information about Biboumi, please see the `official website`_.
This plugin is here as a non-default extension of the poezio configuration
-made to work with IRC rooms and logins. Therefore, it does not define any
-commands or do anything useful except on load.
+made to work with IRC rooms and logins. It also defines commands aimed at
+reducing the amount of effort needed to navigate smoothly between IRC and
+XMPP rooms.
Configuration
-------------
@@ -21,6 +22,14 @@ Global configuration
The JID of the IRC gateway to use. If empty, irc.poez.io will be
used. Please try to run your own, though, it’s painless to setup.
+ initial_connect
+ **Default:** ``true``
+
+ If you want to join all the rooms and try to authenticate with
+ nickserv when the plugin gets loaded. If ``false``, you will have
+ to use the :term:`/irc_login` command to authenticate, and the
+ :term:`/irc_join` command to join preconfigured rooms.
+
.. note:: There is no nickname option because the default from poezio will be used.
Server-specific configuration
@@ -49,14 +58,46 @@ section name, and the following options:
Your nickname on this server. If empty, the default configuration will be used.
- rooms
+ rooms [IRC plugin]
**Default:** ``[empty]``
The list of rooms to join on this server (e.g. ``#room1:#room2``).
.. note:: If no login_command or login_nick is set, the authentication phase
- won’t take place and you will join the rooms after a small delay.
+ won’t take place and you will join the rooms without authentication
+ with nickserv or whatever.
+
+Commands
+~~~~~~~~
+
+.. glossary::
+ :sorted:
+
+ /irc_login
+ **Usage:** ``/irc_login [server1] [server2]…``
+ Authenticate with the specified servers if they are correctly
+ configured. If no servers are provided, the plugin will try
+ them all. (You need to set :term:`login_nick` and
+ :term:`login_command` as well)
+
+ /irc_join
+ **Usage:** ``/irc_join <room or server>``
+
+ Join the specified room on the same server as the current tab (can
+ be a private conversation or a chatroom). If a server that appears
+ in the conversation is specified instead of a room, the plugin
+ will try to join all the rooms configured with autojoin on that
+ server.
+
+ /irc_query
+ **Usage:** ``/irc_query <nickname> [message]``
+
+ Open a private conversation with the given nickname, on the same IRC
+ server as the current tab (can be a private conversation or a
+ chatroom). Doing `/irc_query foo "hello there"` when the current
+ tab is #foo%irc.example.com@biboumi.example.com is equivalent to
+ `/message foo!irc.example.com@biboumi.example.com "hello there"`
Example configuration
~~~~~~~~~~~~~~~~~~~~~
@@ -69,7 +110,7 @@ Example configuration
[irc.freenode.net]
nickname = mynick
login_nick = nickserv
- login_command = identify mynick mypassword
+ login_command = identify mypassword
rooms = #testroom1:#testroom2
[irc.geeknode.org]
@@ -81,41 +122,255 @@ Example configuration
.. _official website: http://biboumi.louiz.org/
+
"""
from plugin import BasePlugin
+from decorators import command_args_parser
+import common
+import tabs
class Plugin(BasePlugin):
- def init(self):
-
- def join(server):
- "Join rooms after a small delay"
- nick = self.config.get('nickname', '', server) or self.core.own_nick
- rooms = self.config.get('rooms', '', server).split(':')
- for room in rooms:
- room = '{}%{}@{}/{}'.format(room, server, gateway, nick)
- self.core.command_join(room)
+ def init(self):
+ if self.config.get('initial_connect', True):
+ self.initial_connect()
+
+ self.api.add_command('irc_login', self.command_irc_login,
+ usage='[server] [server]…',
+ help=('Connect to the specified servers if they '
+ 'exist in the configuration and the login '
+ 'options are set. If not is given, the '
+ 'plugin will try all the sections in the '
+ 'configuration.'),
+ short='Login to irc servers with nickserv',
+ completion=self.completion_irc_login)
+
+ self.api.add_command('irc_join', self.command_irc_join,
+ usage='<room or server>',
+ help=('Join <room> in the same server as the '
+ 'current tab (if it is an IRC tab). Or '
+ 'join all the preconfigured rooms in '
+ '<server> '),
+ short='Join irc rooms more easily',
+ completion=self.completion_irc_join)
+
+ self.api.add_command('irc_query', self.command_irc_query,
+ usage='<nickname> [message]',
+ help=('Open a private conversation with the '
+ 'given <nickname>, on the current IRC '
+ 'server. Optionally immediately send '
+ 'the given message. For example, if the '
+ 'current tab is #foo%irc.example.com@'
+ 'biboumi.example.com, doing `/irc_query '
+ 'nick "hi there"` is equivalent to '
+ '`/message nick!irc.example.com@biboumi.'
+ 'example.com "hi there"`'),
+ short='Open a private conversation with an IRC user')
+
+ def join(self, gateway, server):
+ "Join irc rooms on a server"
+ nick = self.config.get_by_tabname('nickname', server, default='') or self.core.own_nick
+ rooms = self.config.get_by_tabname('rooms', server, default='').split(':')
+ for room in rooms:
+ room = '{}%{}@{}/{}'.format(room, server, gateway, nick)
+ self.core.command_join(room)
+
+ def initial_connect(self):
gateway = self.config.get('gateway', 'irc.poez.io')
sections = self.config.sections()
for section in (s for s in sections if s != 'irc'):
- server_suffix = '%{}@{}'.format(section, gateway)
+
+ room_suffix = '%{}@{}'.format(section, gateway)
already_opened = False
for tab in self.core.tabs:
- if tab.name.endswith(server_suffix):
+ if tab.name.endswith(room_suffix) and tab.joined:
already_opened = True
+ break
- login_command = self.config.get('login_command', '', section)
- login_nick = self.config.get('login_nick', '', section)
- nick = self.config.get('nickname', '', section) or self.core.own_nick
-
+ login_command = self.config.get_by_tabname('login_command', section, default='')
+ login_nick = self.config.get_by_tabname('login_nick', section, default='')
+ nick = self.config.get_by_tabname('nickname', section, default='') or self.core.own_nick
if login_command and login_nick:
- dest = '{}{}/{}'.format(login_nick, server_suffix, nick)
+ def login(gw, sect, log_nick, log_cmd, room_suff):
+ dest = '{}!{}'.format(log_nick, room_suff)
+ self.core.xmpp.send_message(mto=dest, mbody=log_cmd, mtype='chat')
+ delayed = self.api.create_delayed_event(5, self.join, gw, sect)
+ self.api.add_timed_event(delayed)
+ if not already_opened:
+ self.core.command_join(room_suffix + '/' + nick)
+ delayed = self.api.create_delayed_event(5, login, gateway, section,
+ login_nick, login_command,
+ room_suffix[1:])
+ self.api.add_timed_event(delayed)
+ else:
+ login(gateway, section, login_nick, login_command, room_suffix[1:])
+ elif not already_opened:
+ self.join(gateway, section)
+
+ @command_args_parser.quoted(0, -1)
+ def command_irc_login(self, args):
+ """
+ /irc_login [server] [server]…
+ """
+ gateway = self.config.get('gateway', 'irc.poez.io')
+ if args:
+ not_present = []
+ sections = self.config.sections()
+ for section in args:
+ if section not in sections:
+ not_present.append(section)
+ continue
+ login_command = self.config.get_by_tabname('login_command', section, default='')
+ login_nick = self.config.get_by_tabname('login_nick', section, default='')
+ if not login_command and not login_nick:
+ not_present.append(section)
+ continue
+
+ room_suffix = '%{}@{}'.format(section, gateway)
+ dest = '{}!{}'.format(login_nick, room_suffix[1:])
+ self.core.xmpp.send_message(mto=dest, mbody=login_command, mtype='chat')
+ if len(not_present) == 1:
+ self.api.information('Section %s does not exist or is not configured' % not_present[0], 'Warning')
+ elif len(not_present) > 1:
+ self.api.information('Sections %s do not exist or are not configured' % ', '.join(not_present), 'Warning')
+ else:
+ sections = self.config.sections()
+
+ for section in (s for s in sections if s != 'irc'):
+ login_command = self.config.get_by_tabname('login_command', section, default='')
+ login_nick = self.config.get_by_tabname('login_nick', section, default='')
+ if not login_nick and not login_command:
+ continue
+
+ room_suffix = '%{}@{}'.format(section, gateway)
+ dest = '{}!{}'.format(login_nick, room_suffix[1:])
self.core.xmpp.send_message(mto=dest, mbody=login_command, mtype='chat')
- if not already_opened:
- delayed = self.api.create_delayed_event(5, join, section)
- self.api.add_timed_event(delayed)
+
+ def completion_irc_login(self, the_input):
+ """
+ completion for /irc_login
+ """
+ args = the_input.text.split()
+ if '' in args:
+ args.remove('')
+ pos = the_input.get_argument_position()
+ sections = self.config.sections()
+ if 'irc' in sections:
+ sections.remove('irc')
+ for section in args:
+ try:
+ sections.remove(section)
+ except:
+ pass
+ return the_input.new_completion(sections, pos)
+
+ @command_args_parser.quoted(1, 1)
+ def command_irc_join(self, args):
+ """
+ /irc_join <room or server>
+ """
+ if not args:
+ return self.core.command_help('irc_join')
+ sections = self.config.sections()
+ if 'irc' in sections:
+ sections.remove('irc')
+ if args[0] in sections and self.config.get_by_tabname('rooms', args[0]):
+ self.join_server_rooms(args[0])
+ else:
+ self.join_room(args[0])
+
+ @command_args_parser.quoted(1, 1)
+ def command_irc_query(self, args):
+ """
+ Open a private conversation with the given nickname, on the current IRC
+ server.
+ """
+ if args is None:
+ return self.core.command_help('irc_query')
+ current_tab_info = self.get_current_tab_irc_info()
+ if not current_tab_info:
+ return
+ server, gateway = current_tab_info
+ nickname = args[0]
+ message = None
+ if len(args) == 2:
+ message = args[1]
+ jid = '{}!{}@{}'.format(nickname, server, gateway)
+ if message:
+ self.core.command_message('{} "{}"'.format(jid, message))
+ else:
+ self.core.command_message('{}'.format(jid))
+
+ def join_server_rooms(self, section):
+ """
+ Join all the rooms configured for a section
+ (section = irc server)
+ """
+ gateway = self.config.get('gateway', 'irc.poez.io')
+ rooms = self.config.get_by_tabname('rooms', section).split(':')
+ nick = self.config.get_by_tabname('nickname', section)
+ if nick:
+ nick = '/' + nick
+ else:
+ nick = ''
+ suffix = '%{}@{}{}'.format(section, gateway, nick)
+
+ for room in rooms:
+ self.core.command_join(room + suffix)
+
+ def join_room(self, name):
+ """
+ Join a room with only its name and the current tab
+ """
+ current_tab_info = self.get_current_tab_irc_info()
+ if not current_tab_info:
+ return
+ server, gateway = current_tab_info
+
+ room = '{}%{}@{}'.format(name, server, gateway)
+ if self.config.get_by_tabname('nickname', server):
+ room += '/' + self.config.get_by_tabname('nickname', server)
+
+ self.core.command_join(room)
+
+ def get_current_tab_irc_info(self):
+ """
+ Return a tuple with the irc server and the gateway hostnames of the
+ current tab. If the current tab is not an IRC channel or private
+ conversation, a warning is displayed and None is returned
+ """
+ gateway = self.config.get('gateway', 'irc.poez.io')
+ current = self.core.current_tab()
+ current_jid = common.safeJID(current.name)
+ if not current_jid.server == gateway:
+ self.api.information('The current tab does not appear to be an IRC one', 'Warning')
+ return None
+ if isinstance(current, tabs.OneToOneTab):
+ if not '!' in current_jid.node:
+ server = current_jid.node
+ else:
+ ignored, server = current_jid.node.rsplit('!', 1)
+ elif isinstance(current, tabs.MucTab):
+ if not '%' in current_jid.node:
+ server = current_jid.node
+ else:
+ ignored, server = current_jid.node.rsplit('%', 1)
+ else:
+ self.api.information('The current tab does not appear to be an IRC one', 'Warning')
+ return None
+ return server, gateway
+
+ def completion_irc_join(self, the_input):
+ """
+ completion for /irc_join
+ """
+ sections = self.config.sections()
+ if 'irc' in sections:
+ sections.remove('irc')
+ return the_input.new_completion(sections, 1)
+
diff --git a/plugins/otr.py b/plugins/otr.py
index 44fdb323..cceadb99 100644
--- a/plugins/otr.py
+++ b/plugins/otr.py
@@ -71,36 +71,28 @@ Command added to Conversation Tabs and Private Tabs:
*NOT* with multiple rewrites in a secure manner, you should do that
yourself if you want to be sure.
+ /otrsmp
+ **Usage:** ``/otrsmp <ask|answer|abort> [question] [secret]``
-To use OTR, make sure the plugin is loaded (if not, then do ``/load otr``).
+ Verify the identify of your contact by using a pre-defined secret.
-A simple workflow looks like this:
+ - The ``abort`` command aborts an ongoing verification
+ - The ``ask`` command start a verification, with a question or not
+ - The ``answer`` command sends back the answer and finishes the verification
-.. code-block:: none
+Managing trust
+--------------
- /otr start
+An OTR conversation can be started with a simple ``/otr start`` and the
+conversation will be encrypted. However it is very often useful to check
+that your are talking to the right person.
-The status of the OTR encryption should appear in the bar between the chat and
-the input as ``OTR: encrypted``.
+To this end, two actions are available, and a message explaining both
+will be prompted each time an **untrusted** conversation is started:
-Then you use ``fpr``/``ourfpr`` to check the fingerprints, and confirm your respective
-identities out-of-band.
-
-You can then use
-
-.. code-block:: none
-
- /otr trust
-
-To set the key as trusted, which will be shown when you start or refresh a conversation
-(the trust status will be in a bold font and if the key is untrusted, the remote fingerprint
-will be shown).
-
-Once you’re done, end the OTR session with
-
-.. code-block:: none
-
- /otr end
+- Checking the knowledge of a shared secret through the use of :term:`/otrsmp`
+- Exchanging fingerprints (``/otr fpr`` and ``/otr ourfpr``) out of band (in a secure channel) to check that both match,
+ then use ``/otr trust`` to add then to the list of trusted fingerprints for this JID.
Files
-----
@@ -128,20 +120,31 @@ Configuration
Decode embedded XHTML.
- keys_dir
- **Default:** ``$XDG_DATA_HOME/poezio/otr``
+ decode_entities
+ **Default:** ``true``
- The directory in which you want keys and fpr to be stored.
+ Decode XML and HTML entities (like ``&amp;``) even when the
+ document isn't valid (if it is valid, it will be decoded even
+ without this option).
- allow_v2
+ decode_newlines
**Default:** ``true``
- Allow OTRv2
+ Decode ``<br/>`` and ``<br>`` tags even when the document
+ isn't valid (if it is valid, it will be decoded even
+ without this option for ``<br/>``, and ``<br>`` will make
+ the document invalid anyway).
- allow_v1
+ keys_dir
+ **Default:** ``$XDG_DATA_HOME/poezio/otr``
+
+ The directory in which you want keys and fpr to be stored.
+
+ require_encryption
**Default:** ``false``
- Allow OTRv1
+ If ``true``, prevents you from sending unencrypted messages, and tries
+ to establish OTR sessions when receiving unencrypted messages.
timeout
**Default:** ``3``
@@ -151,11 +154,11 @@ Configuration
value will disable this notification.
log
- **Default:** false
+ **Default:** ``false``
Log conversations (OTR start/end marker, and messages).
-The :term:`allow_v1`, :term:`allow_v2`, :term:`decode_xhtml`
+The :term:`require_encryption`, :term:`decode_xhtml`, :term:`decode_entities`
and :term:`log` configuration parameters are tab-specific.
Important details
@@ -177,34 +180,134 @@ import logging
log = logging.getLogger(__name__)
import os
+import html
import curses
from potr.context import NotEncryptedError, UnencryptedMessage, ErrorReceived, NotOTRMessage,\
STATE_ENCRYPTED, STATE_PLAINTEXT, STATE_FINISHED, Context, Account, crypt
+import common
import xhtml
from common import safeJID
from config import config
from plugin import BasePlugin
from tabs import ConversationTab, DynamicConversationTab, PrivateTab
from theming import get_theme, dump_tuple
+from decorators import command_args_parser
OTR_DIR = os.path.join(os.getenv('XDG_DATA_HOME') or
- '~/.local/share', 'poezio', 'otr')
+ '~/.local/share', 'poezio', 'otr')
POLICY_FLAGS = {
- 'ALLOW_V1':False,
- 'ALLOW_V2':True,
- 'REQUIRE_ENCRYPTION': False,
- 'SEND_TAG': True,
- 'WHITESPACE_START_AKE': True,
- 'ERROR_START_AKE': True
+ 'ALLOW_V1':False,
+ 'ALLOW_V2':True,
+ 'REQUIRE_ENCRYPTION': False,
+ 'SEND_TAG': True,
+ 'WHITESPACE_START_AKE': True,
+ 'ERROR_START_AKE': True
}
log = logging.getLogger(__name__)
+OTR_TUTORIAL = _(
+"""%(info)sThis contact has not yet been verified.
+You have several methods of authentication available:
+
+1) Verify each other's fingerprints using a secure (and different) channel:
+Your fingerprint: %(normal)s%(our_fpr)s%(info)s
+%(jid_c)s%(jid)s%(info)s's fingerprint: %(normal)s%(remote_fpr)s%(info)s
+Then use the command: /otr trust
+
+2) SMP pre-shared secret you both know:
+/otrsmp ask <secret>
+
+3) SMP pre-shared secret you both know with a question:
+/otrsmp ask <question> <secret>
+""")
+
+OTR_NOT_ENABLED = _('%(jid_c)s%(jid)s%(info)s did not enable '
+ 'OTR after %(secs)s seconds.')
+
+MESSAGE_NOT_SENT = _('%(info)sYour message to %(jid_c)s%(jid)s%(info)s was'
+ ' not sent because your configuration requires an '
+ 'encrypted session.\nWait until it is established or '
+ 'change your configuration.')
+
+OTR_REQUEST = _('%(info)sOTR request to %(jid_c)s%(jid)s%(info)s sent.')
+
+OTR_OWN_FPR = _('%(info)sYour OTR key fingerprint is '
+ '%(normal)s%(fpr)s%(info)s.')
+
+OTR_REMOTE_FPR = _('%(info)sThe key fingerprint for %(jid_c)s'
+ '%(jid)s%(info)s is %(normal)s%(fpr)s%(info)s.')
+
+OTR_NO_FPR = _('%(jid_c)s%(jid)s%(info)s has no'
+ ' key currently in use.')
+
+OTR_START_TRUSTED = _('%(info)sStarted a \x19btrusted\x19o%(info)s '
+ 'OTR conversation with %(jid_c)s%(jid)s')
+
+OTR_REFRESH_TRUSTED = _('%(info)sRefreshed \x19btrusted\x19o%(info)s'
+ ' OTR conversation with %(jid_c)s%(jid)s')
+
+OTR_START_UNTRUSTED = _('%(info)sStarted an \x19buntrusted\x19o%(info)s'
+ ' OTR conversation with %(jid_c)s%(jid)s')
+
+OTR_REFRESH_UNTRUSTED = _('%(info)sRefreshed \x19buntrusted\x19o%(info)s'
+ ' OTR conversation with %(jid_c)s%(jid)s')
+
+OTR_END = _('%(info)sEnded OTR conversation with %(jid_c)s%(jid)s')
+
+SMP_REQUESTED = _('%(jid_c)s%(jid)s%(info)s has requested SMP verification'
+ '%(q)s%(info)s.\nAnswer with: /otrsmp answer <secret>')
+
+SMP_INITIATED = _('%(info)sInitiated SMP request with '
+ '%(jid_c)s%(jid)s%(info)s.')
+
+SMP_PROGRESS = _('%(info)sSMP progressing.')
+
+SMP_RECIPROCATE = _('%(info)sYou may want to authenticate your peer by asking'
+ ' your own question: /otrsmp ask [question] <secret>')
+
+SMP_SUCCESS = _('%(info)sSMP Verification \x19bsucceeded\x19o%(info)s.')
+
+SMP_FAIL = _('%(info)sSMP Verification \x19bfailed\x19o%(info)s.')
+
+SMP_ABORTED_PEER = _('%(info)sSMP aborted by peer.')
+
+SMP_ABORTED = _('%(info)sSMP aborted.')
+
+MESSAGE_UNENCRYPTED = _('%(info)sThe following message from %(jid_c)s%(jid)s'
+ '%(info)s was \x19bnot\x19o%(info)s encrypted:\x19o\n'
+ '%(msg)s')
+
+MESSAGE_UNREADABLE = _('%(info)sAn encrypted message from %(jid_c)s%(jid)s'
+ '%(info)s was received but is unreadable, as you are'
+ ' not currently communicating privately.')
+
+MESSAGE_INVALID = _('%(info)sThe message from %(jid_c)s%(jid)s%(info)s'
+ ' could not be decrypted.')
+
+OTR_ERROR = _('%(info)sReceived the following error from '
+ '%(jid_c)s%(jid)s%(info)s:\x19o %(err)s')
+
+POTR_ERROR = _('%(info)sAn unspecified error in the OTR plugin occured:\n'
+ '%(exc)s')
+
+TRUST_ADDED = _('%(info)sYou added %(jid_c)s%(bare_jid)s%(info)s with key '
+ '\x19o%(key)s%(info)s to your trusted list.')
+
+
+TRUST_REMOVED = _('%(info)sYou removed %(jid_c)s%(bare_jid)s%(info)s with '
+ 'key \x19o%(key)s%(info)s from your trusted list.')
+
+KEY_DROPPED = _('%(info)sPrivate key dropped.')
+
def hl(tab):
+ """
+ Make a tab beep and change its status.
+ """
if tab.state != 'current':
tab.state = 'private'
@@ -214,12 +317,20 @@ def hl(tab):
curses.beep()
class PoezioContext(Context):
+ """
+ OTR context, specific to a conversation with a contact
+
+ Overrides methods from potr.context.Context
+ """
def __init__(self, account, peer, xmpp, core):
super(PoezioContext, self).__init__(account, peer)
self.xmpp = xmpp
self.core = core
self.flags = {}
self.trustName = safeJID(peer).bare
+ self.in_smp = False
+ self.smp_own = False
+ self.log = 0
def getPolicy(self, key):
if key in self.flags:
@@ -227,6 +338,10 @@ class PoezioContext(Context):
else:
return False
+ def reset_smp(self):
+ self.in_smp = False
+ self.smp_own = False
+
def inject(self, msg, appdata=None):
message = self.xmpp.make_message(mto=self.peer,
mbody=msg.decode('ascii'),
@@ -235,8 +350,13 @@ class PoezioContext(Context):
message.send()
def setState(self, newstate):
- color_jid = '\x19%s}' % dump_tuple(get_theme().COLOR_MUC_JID)
- color_info = '\x19%s}' % dump_tuple(get_theme().COLOR_INFORMATION_TEXT)
+ format_dict = {
+ 'jid_c': '\x19%s}' % dump_tuple(get_theme().COLOR_MUC_JID),
+ 'info': '\x19%s}' % dump_tuple(get_theme().COLOR_INFORMATION_TEXT),
+ 'normal': '\x19%s}' % dump_tuple(get_theme().COLOR_NORMAL_TEXT),
+ 'jid': self.peer,
+ 'bare_jid': safeJID(self.peer).bare
+ }
tab = self.core.get_tab_by_name(self.peer)
if not tab:
@@ -245,55 +365,29 @@ class PoezioContext(Context):
if tab and not tab.locked_resource == safeJID(self.peer).resource:
tab = None
if self.state == STATE_ENCRYPTED:
- if newstate == STATE_ENCRYPTED:
+ if newstate == STATE_ENCRYPTED and tab:
log.debug('OTR conversation with %s refreshed', self.peer)
- if tab:
- if self.getCurrentTrust():
- msg = _('%(info)sRefreshed \x19btrusted\x19o%(info)s'
- ' OTR conversation with %(jid_c)s%(jid)s') % {
- 'info': color_info,
- 'jid_c': color_jid,
- 'jid': self.peer
- }
- tab.add_message(msg, typ=self.log)
- else:
- msg = _('%(info)sRefreshed \x19buntrusted\x19o%(info)s'
- ' OTR conversation with %(jid_c)s%(jid)s'
- '%(info)s, key: \x19o%(key)s') % {
- 'jid': self.peer,
- 'key': self.getCurrentKey(),
- 'info': color_info,
- 'jid_c': color_jid}
-
- tab.add_message(msg, typ=self.log)
- hl(tab)
+ if self.getCurrentTrust():
+ msg = OTR_REFRESH_TRUSTED % format_dict
+ tab.add_message(msg, typ=self.log)
+ else:
+ msg = OTR_REFRESH_UNTRUSTED % format_dict
+ tab.add_message(msg, typ=self.log)
+ hl(tab)
elif newstate == STATE_FINISHED or newstate == STATE_PLAINTEXT:
log.debug('OTR conversation with %s finished', self.peer)
if tab:
- tab.add_message('%sEnded OTR conversation with %s%s' % (
- color_info, color_jid, self.peer),
- typ=self.log)
- hl(tab)
- else:
- if newstate == STATE_ENCRYPTED:
- if tab:
- if self.getCurrentTrust():
- msg = _('%(info)sStarted a \x19btrusted\x19o%(info)s '
- 'OTR conversation with %(jid_c)s%(jid)s') % {
- 'jid': self.peer,
- 'info': color_info,
- 'jid_c': color_jid}
- tab.add_message(msg, typ=self.log)
- else:
- msg = _('%(info)sStarted an \x19buntrusted\x19o%(info)s'
- ' OTR conversation with %(jid_c)s%(jid)s'
- '%(info)s, key: \x19o%(key)s') % {
- 'jid': self.peer,
- 'key': self.getCurrentKey(),
- 'info': color_info,
- 'jid_c': color_jid}
- tab.add_message(msg, typ=self.log)
+ tab.add_message(OTR_END % format_dict, typ=self.log)
hl(tab)
+ elif newstate == STATE_ENCRYPTED and tab:
+ if self.getCurrentTrust():
+ tab.add_message(OTR_START_TRUSTED % format_dict, typ=self.log)
+ else:
+ format_dict['our_fpr'] = self.user.getPrivkey()
+ format_dict['remote_fpr'] = self.getCurrentKey()
+ tab.add_message(OTR_TUTORIAL % format_dict, typ=0)
+ tab.add_message(OTR_START_UNTRUSTED % format_dict, typ=self.log)
+ hl(tab)
log.debug('Set encryption state of %s to %s', self.peer, states[newstate])
super(PoezioContext, self).setState(newstate)
@@ -302,9 +396,14 @@ class PoezioContext(Context):
self.core.doupdate()
class PoezioAccount(Account):
+ """
+ OTR Account, keeps track of a specific account (ours)
+
+ Redefines the load/save methods from potr.context.Account
+ """
def __init__(self, jid, key_dir):
- super(PoezioAccount, self).__init__(jid, 'xmpp', 1024)
+ super(PoezioAccount, self).__init__(jid, 'xmpp', 0)
self.key_dir = os.path.join(key_dir, jid)
def load_privkey(self):
@@ -348,8 +447,7 @@ class PoezioAccount(Account):
with open(self.key_dir + '.fpr', 'w') as fpr_fd:
for uid, trusts in self.trusts.items():
for fpr, trustVal in trusts.items():
- fpr_fd.write('\t'.join(
- (uid, self.name, 'xmpp', fpr, trustVal)))
+ fpr_fd.write('\t'.join((uid, self.name, 'xmpp', fpr, trustVal)))
fpr_fd.write('\n')
except:
log.exception('Error in save_trusts', exc_info=True)
@@ -360,32 +458,29 @@ class PoezioAccount(Account):
savePrivkey = save_privkey
states = {
- STATE_PLAINTEXT: 'plaintext',
- STATE_ENCRYPTED: 'encrypted',
- STATE_FINISHED: 'finished',
+ STATE_PLAINTEXT: 'plaintext',
+ STATE_ENCRYPTED: 'encrypted',
+ STATE_FINISHED: 'finished',
}
class Plugin(BasePlugin):
def init(self):
# set the default values from the config
- allow_v2 = self.config.get('allow_v2', True)
- POLICY_FLAGS['ALLOW_V2'] = allow_v2
- allow_v1 = self.config.get('allow_v1', False)
- POLICY_FLAGS['ALLOW_v1'] = allow_v1
-
global OTR_DIR
OTR_DIR = os.path.expanduser(self.config.get('keys_dir', '') or OTR_DIR)
try:
os.makedirs(OTR_DIR)
except OSError as e:
if e.errno != 17:
- self.api.information('The OTR-specific folder could not be created'
- ' poezio will be unable to save keys and trusts', 'OTR')
+ self.api.information('The OTR-specific folder could not '
+ 'be created. Poezio will be unable '
+ 'to save keys and trusts', 'OTR')
except:
- self.api.information('The OTR-specific folder could not be created'
- ' poezio will be unable to save keys and trusts', 'OTR')
+ self.api.information('The OTR-specific folder could not '
+ 'be created. Poezio will be unable '
+ 'to save keys and trusts', 'OTR')
self.api.add_event_handler('conversation_msg', self.on_conversation_msg)
self.api.add_event_handler('private_msg', self.on_conversation_msg)
@@ -398,7 +493,7 @@ class Plugin(BasePlugin):
self.account = PoezioAccount(self.core.xmpp.boundjid.bare, OTR_DIR)
self.account.load_trusts()
self.contexts = {}
- usage = '[start|refresh|end|fpr|ourfpr|drop|trust|untrust]'
+ usage = '<start|refresh|end|fpr|ourfpr|drop|trust|untrust>'
shortdesc = 'Manage an OTR conversation'
desc = ('Manage an OTR conversation.\n'
'start/refresh: Start or refresh a conversation\n'
@@ -408,16 +503,26 @@ class Plugin(BasePlugin):
'drop: Remove the current key (FOREVER)\n'
'trust: Set this key for this contact as trusted\n'
'untrust: Remove the trust for the key of this contact\n')
+ smp_usage = '<abort|ask|answer> [question] [answer]'
+ smp_short = 'Identify a contact'
+ smp_desc = ('Verify the identify of your contact by using a pre-defined secret.\n'
+ 'abort: Abort an ongoing verification\n'
+ 'ask: Start a verification, with a question or not\n'
+ 'answer: Finish a verification\n')
+
+ self.api.add_tab_command(ConversationTab, 'otrsmp', self.command_smp,
+ help=smp_desc, usage=smp_usage, short=smp_short,
+ completion=self.completion_smp)
+ self.api.add_tab_command(PrivateTab, 'otrsmp', self.command_smp,
+ help=smp_desc, usage=smp_usage, short=smp_short,
+ completion=self.completion_smp)
+
self.api.add_tab_command(ConversationTab, 'otr', self.command_otr,
- help=desc,
- usage=usage,
- short=shortdesc,
- completion=self.completion_otr)
+ help=desc, usage=usage, short=shortdesc,
+ completion=self.completion_otr)
self.api.add_tab_command(PrivateTab, 'otr', self.command_otr,
- help=desc,
- usage=usage,
- short=shortdesc,
- completion=self.completion_otr)
+ help=desc, usage=usage, short=shortdesc,
+ completion=self.completion_otr)
def cleanup(self):
for context in self.contexts.values():
@@ -427,109 +532,159 @@ class Plugin(BasePlugin):
PrivateTab.remove_information_element('otr')
def get_context(self, jid):
+ """
+ Retrieve or create an OTR context
+ """
jid = safeJID(jid).full
if not jid in self.contexts:
flags = POLICY_FLAGS.copy()
- policy = self.config.get_by_tabname('encryption_policy', jid, default='ondemand').lower()
+ require = self.config.get_by_tabname('require_encryption',
+ jid, default=False)
+ flags['REQUIRE_ENCRYPTION'] = require
logging_policy = self.config.get_by_tabname('log', jid, default='false').lower()
- allow_v2 = self.config.get_by_tabname('allow_v2', jid, default='true').lower()
- flags['ALLOW_V2'] = (allow_v2 != 'false')
- allow_v1 = self.config.get_by_tabname('allow_v1', jid, default='false').lower()
- flags['ALLOW_V1'] = (allow_v1 == 'true')
self.contexts[jid] = PoezioContext(self.account, jid, self.core.xmpp, self.core)
self.contexts[jid].log = 1 if logging_policy != 'false' else 0
self.contexts[jid].flags = flags
return self.contexts[jid]
def on_conversation_msg(self, msg, tab):
- color_jid = '\x19%s}' % dump_tuple(get_theme().COLOR_MUC_JID)
- color_info = '\x19%s}' % dump_tuple(get_theme().COLOR_INFORMATION_TEXT)
+ """
+ Message received
+ """
+ format_dict = {
+ 'jid_c': '\x19%s}' % dump_tuple(get_theme().COLOR_MUC_JID),
+ 'info': '\x19%s}' % dump_tuple(get_theme().COLOR_INFORMATION_TEXT),
+ 'jid': msg['from']
+ }
try:
ctx = self.get_context(msg['from'])
txt, tlvs = ctx.receiveMessage(msg["body"].encode('utf-8'))
+
+ # SMP
+ if tlvs:
+ self.handle_tlvs(tlvs, ctx, tab, format_dict)
except UnencryptedMessage as err:
# received an unencrypted message inside an OTR session
- text = _('%(info)sThe following message from %(jid_c)s%(jid)s'
- '%(info)s was \x19bnot\x19o%(info)s encrypted:'
- '\x19o\n%(msg)s') % {
- 'info': color_info,
- 'jid_c': color_jid,
- 'jid': msg['from'],
- 'msg': err.args[0].decode('utf-8')}
- tab.add_message(text, jid=msg['from'],
- typ=0)
- del msg['body']
- del msg['html']
- hl(tab)
- self.core.refresh_window()
+ self.unencrypted_message_received(err, ctx, msg, tab, format_dict)
+ self.otr_start(tab, tab.name, format_dict)
+ return
+ except NotOTRMessage as err:
+ # ignore non-otr messages
+ # if we expected an OTR message, we would have
+ # got an UnencryptedMesssage
+ # but do an additional check because of a bug with potr and py3k
+ if ctx.state != STATE_PLAINTEXT or ctx.getPolicy('REQUIRE_ENCRYPTION'):
+ self.unencrypted_message_received(err, ctx, msg, tab, format_dict)
+ self.otr_start(tab, tab.name, format_dict)
return
except ErrorReceived as err:
# Received an OTR error
- text = _('%(info)sReceived the following error from '
- '%(jid_c)s%(jid)s%(info)s:\x19o %(err)s') % {
- 'jid': msg['from'],
- 'err': err.args[0],
- 'info': color_info,
- 'jid_c': color_jid}
-
- tab.add_message(text, typ=0)
+ format_dict['err'] = err.args[0].error.decode('utf-8', errors='replace')
+ tab.add_message(OTR_ERROR % format_dict, typ=0)
del msg['body']
del msg['html']
hl(tab)
self.core.refresh_window()
return
- except NotOTRMessage as err:
- # ignore non-otr messages
- # if we expected an OTR message, we would have
- # got an UnencryptedMesssage
- # but do an additional check because of a bug with py3k
- if ctx.state != STATE_PLAINTEXT or ctx.getPolicy('REQUIRE_ENCRYPTION'):
-
- text = _('%(info)sThe following message from '
- '%(jid_c)s%(jid)s%(info)s was \x19b'
- 'not\x19o%(info)s encrypted:\x19o\n%(msg)s') % {
- 'jid': msg['from'],
- 'msg': err.args[0].decode('utf-8'),
- 'info': color_info,
- 'jid_c': color_jid}
- tab.add_message(text, jid=msg['from'],
- typ=ctx.log)
- del msg['body']
- del msg['html']
- hl(tab)
- self.core.refresh_window()
- return
- return
except NotEncryptedError as err:
- text = _('%(info)sAn encrypted message from %(jid_c)s%(jid)s'
- '%(info)s was received but is unreadable, as you are'
- ' not currently communicating privately.') % {
- 'info': color_info,
- 'jid_c': color_jid,
- 'jid': msg['from']}
- tab.add_message(text, jid=msg['from'],
- typ=0)
+ # Encrypted message received, but unreadable as we do not have
+ # an OTR session in place.
+ text = MESSAGE_UNREADABLE % format_dict
+ tab.add_message(text, jid=msg['from'], typ=0)
hl(tab)
del msg['body']
del msg['html']
self.core.refresh_window()
return
except crypt.InvalidParameterError:
- tab.add_message('%sThe message from %s%s%s could not be decrypted.'
- % (color_info, color_jid, msg['from'], color_info),
- jid=msg['from'], typ=0)
+ # Malformed OTR payload and stuff
+ text = MESSAGE_INVALID % format_dict
+ tab.add_message(text, jid=msg['from'], typ=0)
hl(tab)
del msg['body']
del msg['html']
self.core.refresh_window()
return
- except:
- tab.add_message('%sAn unspecified error in the OTR plugin occured'
- % color_info,
- typ=0)
+ except Exception:
+ # Unexpected error
+ import traceback
+ exc = traceback.format_exc()
+ format_dict['exc'] = exc
+ tab.add_message(POTR_ERROR % format_dict, typ=0)
log.error('Unspecified error in the OTR plugin', exc_info=True)
return
+ # No error, proceed with the message
+ self.encrypted_message_received(msg, ctx, tab, txt)
+ def handle_tlvs(self, tlvs, ctx, tab, format_dict):
+ """
+ If the message had a TLV, it means we received part of an SMP
+ exchange.
+ """
+ smp1q = get_tlv(tlvs, potr.proto.SMP1QTLV)
+ smp1 = get_tlv(tlvs, potr.proto.SMP1TLV)
+ smp2 = get_tlv(tlvs, potr.proto.SMP2TLV)
+ smp3 = get_tlv(tlvs, potr.proto.SMP3TLV)
+ smp4 = get_tlv(tlvs, potr.proto.SMP4TLV)
+ abort = get_tlv(tlvs, potr.proto.SMPABORTTLV)
+ if abort:
+ ctx.reset_smp()
+ tab.add_message(SMP_ABORTED_PEER % format_dict, typ=0)
+ elif ctx.in_smp and not ctx.smpIsValid():
+ ctx.reset_smp()
+ tab.add_message(SMP_ABORTED % format_dict, typ=0)
+ elif smp1 or smp1q:
+ # Received an SMP request (with a question or not)
+ if smp1q:
+ try:
+ question = ' with question: \x19o' + smp1q.msg.decode('utf-8')
+ except UnicodeDecodeError:
+ self.api.information('The peer sent a question but it had a wrong encoding', 'Error')
+ question = ''
+ else:
+ question = ''
+ ctx.in_smp = True
+ # we did not initiate it
+ ctx.smp_own = False
+ format_dict['q'] = question
+ tab.add_message(SMP_REQUESTED % format_dict, typ=0)
+ elif smp2:
+ # SMP reply received
+ if not ctx.in_smp:
+ ctx.reset_smp()
+ else:
+ tab.add_message(SMP_PROGRESS % format_dict, typ=0)
+ elif smp3 or smp4:
+ # Type 4 (SMP message 3) or 5 (SMP message 4) TLVs received
+ # in both cases it is the final message of the SMP exchange
+ if ctx.smpIsSuccess():
+ tab.add_message(SMP_SUCCESS % format_dict, typ=0)
+ if not ctx.getCurrentTrust():
+ tab.add_message(SMP_RECIPROCATE % format_dict, typ=0)
+ else:
+ tab.add_message(SMP_FAIL % format_dict, typ=0)
+ ctx.reset_smp()
+ hl(tab)
+ self.core.refresh_window()
+
+ def unencrypted_message_received(self, err, ctx, msg, tab, format_dict):
+ """
+ An unencrypted message was received while we expected it to be
+ encrypted. Display it with a warning.
+ """
+ format_dict['msg'] = err.args[0].decode('utf-8')
+ text = MESSAGE_UNENCRYPTED % format_dict
+ tab.add_message(text, jid=msg['from'], typ=ctx.log)
+ del msg['body']
+ del msg['html']
+ hl(tab)
+ self.core.refresh_window()
+
+ def encrypted_message_received(self, msg, ctx, tab, txt):
+ """
+ A properly encrypted message was received, so we add it to the
+ buffer, and try to format it according to the configuration.
+ """
# remove xhtml
del msg['html']
del msg['body']
@@ -544,11 +699,25 @@ class Plugin(BasePlugin):
nick_color = get_theme().COLOR_REMOTE_USER
body = txt.decode()
+ decode_entities = self.config.get_by_tabname('decode_entities',
+ msg['from'].bare,
+ default=True)
+ decode_newlines = self.config.get_by_tabname('decode_newlines',
+ msg['from'].bare,
+ default=True)
if self.config.get_by_tabname('decode_xhtml', msg['from'].bare, default=True):
try:
body = xhtml.xhtml_to_poezio_colors(body, force=True)
- except:
- pass
+ except Exception:
+ if decode_entities:
+ body = html.unescape(body)
+ if decode_newlines:
+ body = body.replace('<br/>', '\n').replace('<br>', '\n')
+ else:
+ if decode_entities:
+ body = html.unescape(body)
+ if decode_newlines:
+ body = body.replace('<br/>', '\n').replace('<br>', '\n')
tab.add_message(body, nickname=tab.nick, jid=msg['from'],
forced_user=user, typ=ctx.log,
nick_color=nick_color)
@@ -556,113 +725,127 @@ class Plugin(BasePlugin):
self.core.refresh_window()
del msg['body']
+ def find_encrypted_context_with_matching(self, bare_jid):
+ """
+ Find an OTR session from a bare JID.
+
+ Useful when a dynamic tab unlocks, which would lead to sending
+ unencrypted messages until it locks again, if we didn’t fallback
+ with this.
+ """
+ for ctx in self.contexts:
+ if safeJID(ctx).bare == bare_jid and self.contexts[ctx].state == STATE_ENCRYPTED:
+ return self.contexts[ctx]
+ return None
+
def on_conversation_say(self, msg, tab):
"""
On message sent
"""
if isinstance(tab, DynamicConversationTab) and tab.locked_resource:
- name = safeJID(tab.name)
- name.resource = tab.locked_resource
- name = name.full
+ jid = safeJID(tab.name)
+ jid.resource = tab.locked_resource
+ name = jid.full
else:
name = tab.name
+ jid = safeJID(tab.name)
+
+ format_dict = {
+ 'jid_c': '\x19%s}' % dump_tuple(get_theme().COLOR_MUC_JID),
+ 'info': '\x19%s}' % dump_tuple(get_theme().COLOR_INFORMATION_TEXT),
+ 'jid': name,
+ }
+
ctx = self.contexts.get(name)
+ if isinstance(tab, DynamicConversationTab) and not tab.locked_resource:
+ log.debug('Unlocked tab %s found, falling back to the first encrypted chat we find.', name)
+ ctx = self.find_encrypted_context_with_matching(jid.bare)
+
if ctx and ctx.state == STATE_ENCRYPTED:
ctx.sendMessage(0, msg['body'].encode('utf-8'))
if not tab.send_chat_state('active'):
tab.send_chat_state('inactive', always_send=True)
tab.add_message(msg['body'],
- nickname=self.core.own_nick or tab.own_nick,
- nick_color=get_theme().COLOR_OWN_NICK,
- identifier=msg['id'],
- jid=self.core.xmpp.boundjid,
- typ=ctx.log)
+ nickname=self.core.own_nick or tab.own_nick,
+ nick_color=get_theme().COLOR_OWN_NICK,
+ identifier=msg['id'],
+ jid=self.core.xmpp.boundjid,
+ typ=ctx.log)
# remove everything from the message so that it doesn’t get sent
del msg['body']
del msg['replace']
del msg['html']
+ elif ctx and ctx.getPolicy('REQUIRE_ENCRYPTION'):
+ tab.add_message(MESSAGE_NOT_SENT % format_dict, typ=0)
+ del msg['body']
+ del msg['replace']
+ del msg['html']
+ self.otr_start(tab, name, format_dict)
def display_encryption_status(self, jid):
+ """
+ Returns the text to display in the infobar (the OTR status)
+ """
context = self.get_context(jid)
+ if safeJID(jid).bare == jid and context.state != STATE_ENCRYPTED:
+ ctx = self.find_encrypted_context_with_matching(jid)
+ if ctx:
+ context = ctx
state = states[context.state]
- return ' OTR: %s' % state
+ trust = 'trusted' if context.getCurrentTrust() else 'untrusted'
+
+ return ' OTR: %s (%s)' % (state, trust)
def command_otr(self, arg):
"""
/otr [start|refresh|end|fpr|ourfpr]
"""
- arg = arg.strip()
+ args = common.shell_split(arg)
+ if not args:
+ return self.core.command_help('otr')
+ action = args.pop(0)
tab = self.api.current_tab()
name = tab.name
- color_jid = '\x19%s}' % dump_tuple(get_theme().COLOR_MUC_JID)
- color_info = '\x19%s}' % dump_tuple(get_theme().COLOR_INFORMATION_TEXT)
- color_normal = '\x19%s}' % dump_tuple(get_theme().COLOR_NORMAL_TEXT)
if isinstance(tab, DynamicConversationTab) and tab.locked_resource:
name = safeJID(tab.name)
name.resource = tab.locked_resource
name = name.full
- if arg == 'end': # close the session
+ format_dict = {
+ 'jid_c': '\x19%s}' % dump_tuple(get_theme().COLOR_MUC_JID),
+ 'info': '\x19%s}' % dump_tuple(get_theme().COLOR_INFORMATION_TEXT),
+ 'normal': '\x19%s}' % dump_tuple(get_theme().COLOR_NORMAL_TEXT),
+ 'jid': name,
+ 'bare_jid': safeJID(name).bare
+ }
+
+ if action == 'end': # close the session
context = self.get_context(name)
context.disconnect()
- elif arg == 'start' or arg == 'refresh':
- otr = self.get_context(name)
- secs = self.config.get('timeout', 3)
- def notify_otr_timeout():
- if otr.state != STATE_ENCRYPTED:
- text = _('%(jid_c)s%(jid)s%(info)s did not enable'
- ' OTR after %(sec)s seconds.') % {
- 'jid': tab.name,
- 'info': color_info,
- 'jid_c': color_jid,
- 'sec': secs}
- tab.add_message(text, typ=0)
- self.core.refresh_window()
- if secs > 0:
- event = self.api.create_delayed_event(secs, notify_otr_timeout)
- self.api.add_timed_event(event)
- self.core.xmpp.send_message(mto=name, mtype='chat',
- mbody=self.contexts[name].sendMessage(0, b'?OTRv?').decode())
- text = _('%(info)sOTR request to %(jid_c)s%(jid)s%(info)s sent.') % {
- 'jid': tab.name,
- 'info': color_info,
- 'jid_c': color_jid}
- tab.add_message(text, typ=0)
- elif arg == 'ourfpr':
- fpr = self.account.getPrivkey()
- text = _('%(info)sYour OTR key fingerprint is %(norm)s%(fpr)s.') % {
- 'jid': tab.name,
- 'info': color_info,
- 'norm': color_normal,
- 'fpr': fpr}
- tab.add_message(text, typ=0)
- elif arg == 'fpr':
+ if isinstance(tab, DynamicConversationTab) and not tab.locked_resource:
+ ctx = self.find_encrypted_context_with_matching(safeJID(name).bare)
+ ctx.disconnect()
+ elif action == 'start' or action == 'refresh':
+ self.otr_start(tab, name, format_dict)
+ elif action == 'ourfpr':
+ format_dict['fpr'] = self.account.getPrivkey()
+ tab.add_message(OTR_OWN_FPR % format_dict, typ=0)
+ elif action == 'fpr':
if name in self.contexts:
ctx = self.contexts[name]
if ctx.getCurrentKey() is not None:
- text = _('%(info)sThe key fingerprint for %(jid_c)s'
- '%(jid)s%(info)s is %(norm)s%(fpr)s%(info)s.') % {
- 'jid': tab.name,
- 'info': color_info,
- 'norm': color_normal,
- 'jid_c': color_jid,
- 'fpr': ctx.getCurrentKey()}
- tab.add_message(text, typ=0)
+ format_dict['fpr'] = ctx.getCurrentKey()
+ tab.add_message(OTR_REMOTE_FPR % format_dict, typ=0)
else:
- text = _('%(jid_c)s%(jid)s%(info)s has no'
- ' key currently in use.') % {
- 'jid': tab.name,
- 'info': color_info,
- 'jid_c': color_jid}
- tab.add_message(text, typ=0)
- elif arg == 'drop':
+ tab.add_message(OTR_NO_FPR % format_dict, typ=0)
+ elif action == 'drop':
# drop the privkey (and obviously, end the current conversations before that)
for context in self.contexts.values():
if context.state not in (STATE_FINISHED, STATE_PLAINTEXT):
context.disconnect()
self.account.drop_privkey()
- tab.add_message('%sPrivate key dropped.' % color_info, typ=0)
- elif arg == 'trust':
+ tab.add_message(KEY_DROPPED % format_dict, typ=0)
+ elif action == 'trust':
ctx = self.get_context(name)
key = ctx.getCurrentKey()
if key:
@@ -670,16 +853,11 @@ class Plugin(BasePlugin):
else:
return
if not ctx.getCurrentTrust():
+ format_dict['key'] = key
ctx.setTrust(fpr, 'verified')
self.account.saveTrusts()
- text = _('%(info)sYou added %(jid_c)s%(jid)s%(info)s with key '
- '\x19o%(key)s%(info)s to your trusted list.') % {
- 'jid': ctx.trustName,
- 'key': key,
- 'info': color_info,
- 'jid_c': color_jid}
- tab.add_message(text, typ=0)
- elif arg == 'untrust':
+ tab.add_message(TRUST_ADDED % format_dict, typ=0)
+ elif action == 'untrust':
ctx = self.get_context(name)
key = ctx.getCurrentKey()
if key:
@@ -687,19 +865,108 @@ class Plugin(BasePlugin):
else:
return
if ctx.getCurrentTrust():
+ format_dict['key'] = key
ctx.setTrust(fpr, '')
self.account.saveTrusts()
- text = _('%(info)sYou removed %(jid_c)s%(jid)s%(info)s with '
- 'key \x19o%(key)s%(info)s from your trusted list.') % {
- 'jid': ctx.trustName,
- 'key': key,
- 'info': color_info,
- 'jid_c': color_jid}
-
- tab.add_message(text, typ=0)
+ tab.add_message(TRUST_REMOVED % format_dict, typ=0)
self.core.refresh_window()
- def completion_otr(self, the_input):
+ def otr_start(self, tab, name, format_dict):
+ """
+ Start an otr conversation with a contact
+ """
+ secs = self.config.get('timeout', 3)
+ def notify_otr_timeout():
+ tab_name = tab.name
+ otr = self.get_context(tab_name)
+ if isinstance(tab, DynamicConversationTab):
+ if tab.locked_resource:
+ tab_name = safeJID(tab.name)
+ tab_name.resource = tab.locked_resource
+ tab_name = tab_name.full
+ otr = self.get_context(tab_name)
+ if otr.state != STATE_ENCRYPTED:
+ format_dict['secs'] = secs
+ text = OTR_NOT_ENABLED % format_dict
+ tab.add_message(text, typ=0)
+ self.core.refresh_window()
+ if secs > 0:
+ event = self.api.create_delayed_event(secs, notify_otr_timeout)
+ self.api.add_timed_event(event)
+ body = self.get_context(name).sendMessage(0, b'?OTRv?').decode()
+ self.core.xmpp.send_message(mto=name, mtype='chat', mbody=body)
+ tab.add_message(OTR_REQUEST % format_dict, typ=0)
+
+ @staticmethod
+ def completion_otr(the_input):
+ """
+ Completion for /otr
+ """
comp = ['start', 'fpr', 'ourfpr', 'refresh', 'end', 'trust', 'untrust']
return the_input.new_completion(comp, 1, quotify=False)
+ @command_args_parser.quoted(1, 2)
+ def command_smp(self, args):
+ """
+ /otrsmp <ask|answer|abort> [question] [secret]
+ """
+ if args is None or not args:
+ return self.core.command_help('otrsmp')
+ length = len(args)
+ action = args.pop(0)
+ if length == 2:
+ question = None
+ secret = args.pop(0).encode('utf-8')
+ elif length == 3:
+ question = args.pop(0).encode('utf-8')
+ secret = args.pop(0).encode('utf-8')
+ else:
+ question = secret = None
+
+ tab = self.api.current_tab()
+ name = tab.name
+ if isinstance(tab, DynamicConversationTab) and tab.locked_resource:
+ name = safeJID(tab.name)
+ name.resource = tab.locked_resource
+ name = name.full
+
+ format_dict = {
+ 'jid_c': '\x19%s}' % dump_tuple(get_theme().COLOR_MUC_JID),
+ 'info': '\x19%s}' % dump_tuple(get_theme().COLOR_INFORMATION_TEXT),
+ 'jid': name,
+ 'bare_jid': safeJID(name).bare
+ }
+
+ ctx = self.get_context(name)
+ if ctx.state != STATE_ENCRYPTED:
+ self.api.information('The current conversation is not encrypted',
+ 'Error')
+ return
+
+ if action == 'ask':
+ ctx.in_smp = True
+ ctx.smp_own = True
+ if question:
+ ctx.smpInit(secret, question)
+ else:
+ ctx.smpInit(secret)
+ tab.add_message(SMP_INITIATED % format_dict, typ=0)
+ elif action == 'answer':
+ ctx.smpGotSecret(secret)
+ elif action == 'abort':
+ if ctx.in_smp:
+ ctx.smpAbort()
+ tab.add_message(SMP_ABORTED % format_dict, typ=0)
+ self.core.refresh_window()
+
+ @staticmethod
+ def completion_smp(the_input):
+ """Completion for /otrsmp"""
+ if the_input.get_argument_position() == 1:
+ return the_input.new_completion(['ask', 'answer', 'abort'], 1, quotify=False)
+
+def get_tlv(tlvs, cls):
+ """Find the instance of a class in a list"""
+ for tlv in tlvs:
+ if isinstance(tlv, cls):
+ return tlv
diff --git a/plugins/pipe_cmd.py b/plugins/pipe_cmd.py
index 762501ae..29404e0f 100644
--- a/plugins/pipe_cmd.py
+++ b/plugins/pipe_cmd.py
@@ -2,6 +2,20 @@
This plugins allows commands to be sent to poezio via a named pipe.
+You can run the same commands that you would in the poezio input
+(e.g. ``echo '/message toto@example.tld Hi' >> /tmp/poezio.fifo``).
+
+Configuration
+-------------
+
+.. glossary::
+ :sorted:
+
+ pipename
+ **Default:** :file:`/tmp/poezio.fifo`
+
+ The path to the fifo which will receive commands.
+
"""
diff --git a/plugins/reorder.py b/plugins/reorder.py
new file mode 100644
index 00000000..13d873e7
--- /dev/null
+++ b/plugins/reorder.py
@@ -0,0 +1,129 @@
+"""
+``reorder`` plugin: Reorder the tabs according to a layout
+
+Commands
+--------
+
+.. glossary::
+
+ /reorder
+ **Usage:** ``/reorder``
+
+ Reorder the tabs according to the configuration.
+
+
+Configuration
+-------------
+
+The configuration file must contain a section ``[reorder]`` and each option
+must be formatted like ``[tab number] = [tab type]:[tab name]``.
+
+For example:
+
+.. code-block:: ini
+
+ [reorder]
+ 1 = muc:toto@conference.example.com
+ 2 = muc:example@muc.example.im
+ 3 = dynamic:robert@example.org
+
+The ``[tab number]`` must be at least ``1``; if the range is not entirely
+covered, e.g.:
+
+.. code-block:: ini
+
+ [reorder]
+ 1 = muc:toto@conference.example.com
+ 3 = dynamic:robert@example.org
+
+Poezio will insert gaps between the tabs in order to keep the specified
+numbering (so in this case, there will be a tab 1, a tab 3, but no tab 2).
+
+
+The ``[tab type]`` must be one of:
+
+- ``muc`` (for multi-user chats)
+- ``private`` (for chats with a specific user inside a multi-user chat)
+- ``dynamic`` (for normal, dynamic conversations tabs)
+- ``static`` (for conversations with a specific resource)
+
+And finally, the ``[tab name]`` must be:
+
+- For a type ``muc``, the bare JID of the room
+- For a type ``private``, the full JID of the user (room JID with the username as a resource)
+- For a type ``dynamic``, the bare JID of the contact
+- For a type ``static``, the full JID of the contact
+"""
+from plugin import BasePlugin
+import tabs
+from decorators import command_args_parser
+
+mapping = {
+ 'muc': tabs.MucTab,
+ 'private': tabs.PrivateTab,
+ 'dynamic': tabs.DynamicConversationTab,
+ 'static': tabs.StaticConversationTab,
+ 'empty': tabs.GapTab
+}
+
+def parse_config(config):
+ result = {}
+ for option in config.options('reorder'):
+ if not option.isdecimal():
+ continue
+ pos = int(option)
+ if pos in result or pos <= 0:
+ return
+
+ typ, name = config.get(option, default=':').split(':', maxsplit=1)
+ if typ not in mapping:
+ return
+ result[pos] = (mapping[typ], name)
+
+ return result
+
+class Plugin(BasePlugin):
+ def init(self):
+ self.api.add_command('reorder', self.command_reorder,
+ help='Reorder all tabs.')
+
+ @command_args_parser.ignored
+ def command_reorder(self):
+ """
+ /reorder
+ """
+ self.core.go_to_roster()
+ self.core.current_tab_nb = 0
+
+ tabs_spec = parse_config(self.config)
+ if not tabs_spec:
+ return self.api.information('Invalid reorder config', 'Error')
+
+ old_tabs = self.core.tabs[1:]
+ roster = self.core.tabs[0]
+
+ new_tabs = []
+ last = 0
+ for pos in sorted(tabs_spec):
+ if pos > last + 1:
+ new_tabs += [tabs.GapTab() for i in range(pos - last)]
+ cls, name = tabs_spec[pos]
+ tab = self.core.get_tab_by_name(name, typ=cls)
+ if tab and tab in old_tabs:
+ new_tabs.append(tab)
+ old_tabs.remove(tab)
+ else:
+ self.api.information('Tab %s not found' % name, 'Warning')
+ new_tabs.append(tabs.GapTab())
+ last = pos
+
+ for tab in old_tabs:
+ if tab:
+ new_tabs.append(tab)
+
+ self.core.tabs.clear()
+ self.core.tabs.append(roster)
+ self.core.tabs += new_tabs
+
+ self.core.refresh_window()
+
diff --git a/plugins/screen_detach.py b/plugins/screen_detach.py
index 53827c11..2b42d01a 100644
--- a/plugins/screen_detach.py
+++ b/plugins/screen_detach.py
@@ -1,35 +1,91 @@
"""
This plugin will set your status to **away** if you detach your screen.
+The default behaviour is to check for both tmux and screen (in that order).
+
+Configuration options
+---------------------
+
+.. glossary::
+
+ use_screen
+ **Default:** ``true``
+
+ Try to find an attached screen.
+
+ use_tmux
+ **Default:** ``true``
+
+ Try to find and attached tmux.
+
"""
+
from plugin import BasePlugin
import os
import stat
import pyinotify
+import asyncio
+
+DEFAULT_CONFIG = {
+ 'screen_detach': {
+ 'use_tmux': True,
+ 'use_screen': True
+ }
+}
+
+
+# overload if this is not how your stuff
+# is configured
+try:
+ LOGIN = os.getlogin()
+ LOGIN_TMUX = os.getuid()
+except Exception:
+ LOGIN = os.getenv('USER')
+ LOGIN_TMUX = os.getuid()
+
+SCREEN_DIR = '/var/run/screens/S-%s' % LOGIN
+TMUX_DIR = '/tmp/tmux-%s' % LOGIN_TMUX
+
+def find_screen(path):
+ if not os.path.isdir(path):
+ return
+ for f in os.listdir(path):
+ path = os.path.join(path, f)
+ if screen_attached(path):
+ return path
+
+def screen_attached(socket):
+ return (os.stat(socket).st_mode & stat.S_IXUSR) != 0
+
+class Plugin(BasePlugin, pyinotify.Notifier):
+
+ default_config = DEFAULT_CONFIG
-class Plugin(BasePlugin):
def init(self):
- screen_dir = '/var/run/screen/S-%s' % (os.getlogin(),)
- self.timed_event = None
sock_path = None
- self.thread = None
- for f in os.listdir(screen_dir):
- path = os.path.join(screen_dir, f)
- if screen_attached(path):
- sock_path = path
- self.attached = True
- break
+ if self.config.get('use_tmux'):
+ sock_path = find_screen(TMUX_DIR)
+ if sock_path is None and self.config.get('use_screen'):
+ sock_path = find_screen(SCREEN_DIR)
# Only actually do something if we found an attached screen (assuming only one)
if sock_path:
+ self.attached = True
wm = pyinotify.WatchManager()
wm.add_watch(sock_path, pyinotify.EventsCodes.ALL_FLAGS['IN_ATTRIB'])
- self.thread = pyinotify.ThreadedNotifier(wm, default_proc_fun=HandleScreen(plugin=self))
- self.thread.start()
+ pyinotify.Notifier.__init__(self, wm, default_proc_fun=HandleScreen(plugin=self))
+ asyncio.get_event_loop().add_reader(self._fd, self.process)
+ else:
+ self.api.information('screen_detach plugin: No tmux or screen found',
+ 'Warning')
+ self.attached = False
+
+ def process(self):
+ self.read_events()
+ self.process_events()
def cleanup(self):
- if self.thread:
- self.thread.stop()
+ asyncio.get_event_loop().remove_reader(self._fd)
def update_screen_state(self, socket):
attached = screen_attached(socket)
@@ -38,9 +94,6 @@ class Plugin(BasePlugin):
status = 'available' if self.attached else 'away'
self.core.command_status(status)
-def screen_attached(socket):
- return (os.stat(socket).st_mode & stat.S_IXUSR) != 0
-
class HandleScreen(pyinotify.ProcessEvent):
def my_init(self, **kwargs):
self.plugin = kwargs['plugin']
diff --git a/plugins/simple_notify.py b/plugins/simple_notify.py
index f08fa259..9311efed 100644
--- a/plugins/simple_notify.py
+++ b/plugins/simple_notify.py
@@ -21,7 +21,7 @@ Second example:
.. code-block:: ini
[simple_notify]
- command = echo \\<%{from}s\\> %{body}s >> some.fifo
+ command = echo \\<%(from)s\\> %(body)s >> some.fifo
delay = 3
after_command = echo >> some.fifo
diff --git a/plugins/uptime.py b/plugins/uptime.py
index dbeb6a63..a36274e6 100644
--- a/plugins/uptime.py
+++ b/plugins/uptime.py
@@ -31,6 +31,6 @@ class Plugin(BasePlugin):
jid = safeJID(arg)
if not jid.server:
return
- iq = self.core.xmpp.makeIqGet(ito=jid.server)
+ iq = self.core.xmpp.make_iq_get(ito=jid.server)
iq.append(ET.Element('{jabber:iq:last}query'))
iq.send(callback=callback)