diff options
Diffstat (limited to 'plugins')
-rw-r--r-- | plugins/autocorrect.py | 55 | ||||
-rw-r--r-- | plugins/close_all.py | 45 | ||||
-rw-r--r-- | plugins/cyber.py | 40 | ||||
-rw-r--r-- | plugins/gpg/__init__.py | 40 | ||||
-rw-r--r-- | plugins/gpg/gnupg.py | 566 | ||||
-rw-r--r-- | plugins/irc.py | 303 | ||||
-rw-r--r-- | plugins/otr.py | 781 | ||||
-rw-r--r-- | plugins/pipe_cmd.py | 14 | ||||
-rw-r--r-- | plugins/reorder.py | 129 | ||||
-rw-r--r-- | plugins/screen_detach.py | 87 | ||||
-rw-r--r-- | plugins/simple_notify.py | 2 | ||||
-rw-r--r-- | plugins/uptime.py | 2 |
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 ``&``) 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) |