diff options
author | Emmanuel Gil Peyrot <linkmauve@linkmauve.fr> | 2016-03-31 18:54:41 +0100 |
---|---|---|
committer | Emmanuel Gil Peyrot <linkmauve@linkmauve.fr> | 2016-06-11 20:49:43 +0100 |
commit | 332a5c2553db41de777473a1e1be9cd1522c9496 (patch) | |
tree | 3ee06a59f147ccc4009b35cccfbe2461bcd18310 /poezio | |
parent | cf44cf7cdec9fdb35caa372563d57e7045dc29dd (diff) | |
download | poezio-332a5c2553db41de777473a1e1be9cd1522c9496.tar.gz poezio-332a5c2553db41de777473a1e1be9cd1522c9496.tar.bz2 poezio-332a5c2553db41de777473a1e1be9cd1522c9496.tar.xz poezio-332a5c2553db41de777473a1e1be9cd1522c9496.zip |
Move the src directory to poezio, for better cython compatibility.
Diffstat (limited to 'poezio')
61 files changed, 21209 insertions, 0 deletions
diff --git a/poezio/args.py b/poezio/args.py new file mode 100644 index 00000000..63e77927 --- /dev/null +++ b/poezio/args.py @@ -0,0 +1,28 @@ +""" +Module related to the argument parsing + +There is a fallback to the deprecated optparse if argparse is not found +""" +from os import path +from argparse import ArgumentParser, SUPPRESS + +def parse_args(CONFIG_PATH=''): + """ + Parse the arguments from the command line + """ + parser = ArgumentParser('poezio') + parser.add_argument("-c", "--check-config", dest="check_config", + action='store_true', + help='Check the config file') + parser.add_argument("-d", "--debug", dest="debug", + help="The file where debug will be written", + metavar="DEBUG_FILE") + parser.add_argument("-f", "--file", dest="filename", + default=path.join(CONFIG_PATH, 'poezio.cfg'), + help="The config file you want to use", + metavar="CONFIG_FILE") + parser.add_argument("-v", "--version", dest="version", + help=SUPPRESS, metavar="VERSION", + default="1.0-dev") + options = parser.parse_args() + return options diff --git a/poezio/bookmarks.py b/poezio/bookmarks.py new file mode 100644 index 00000000..c7d26a51 --- /dev/null +++ b/poezio/bookmarks.py @@ -0,0 +1,289 @@ +""" +Bookmarks module + +Therein the bookmark class is defined, representing one conference room. +This object is used to generate elements for both local and remote +bookmark storage. It can also parse xml Elements. + +This module also defines several functions for retrieving and updating +bookmarks, both local and remote. + +Poezio start scenario: + +- upon inital connection, poezio will disco#info the server +- the available storage methods will be stored in the available_storage dict + (either 'pep' or 'privatexml') +- if only one is available, poezio will set the use_bookmarks_method config option + to it. If both are, it will be set to 'privatexml' (or if it was previously set, the + value will be kept). +- it will then query the preferred storages for bookmarks and cache them locally + (Bookmark objects with a method='remote' attribute) + +Adding a remote bookmark: + +- New Bookmark object added to the list with storage='remote' +- All bookmarks are sent to the storage selected in use_bookmarks_method + if there was an error, the user is notified. + + +""" + +import functools +import logging + +from slixmpp.plugins.xep_0048 import Bookmarks, Conference, URL +from slixmpp import JID +from common import safeJID +from config import config + +log = logging.getLogger(__name__) + + +class Bookmark(object): + + def __init__(self, jid, name=None, autojoin=False, nick=None, password=None, method='local'): + self.jid = jid + self.name = name or jid + self.autojoin = autojoin + self.nick = nick + self.password = password + self._method = method + + @property + def method(self): + return self._method + + @method.setter + def method(self, value): + if value not in ('local', 'remote'): + log.debug('Could not set bookmark storing method: %s', value) + return + self._method = value + + def __repr__(self): + return '<%s%s|%s>' % (self.jid, + ('/'+self.nick) if self.nick else '', + self.method) + + def stanza(self): + """ + Generate a <conference/> stanza from the instance + """ + el = Conference() + el['name'] = self.name + el['jid'] = self.jid + el['autojoin'] = 'true' if self.autojoin else 'false' + if self.nick: + el['nick'] = self.nick + if self.password: + el['password'] = self.password + return el + + def local(self): + """Generate a str for local storage""" + local = self.jid + if self.nick: + local += '/%s' % self.nick + local += ':' + if self.password: + config.set_and_save('password', self.password, section=self.jid) + return local + + @functools.singledispatch + @staticmethod + def parse(el): + """ + Generate a Bookmark object from a <conference/> element + (this is a fallback for raw XML Elements) + """ + jid = el.get('jid') + name = el.get('name') + autojoin = True if el.get('autojoin', 'false').lower() in ('true', '1') else False + nick = None + for n in el.iter('nick'): + nick = n.text + password = None + for p in el.iter('password'): + password = p.text + + return Bookmark(jid, name, autojoin, nick, password, method='remote') + + @staticmethod + @parse.register(Conference) + def parse_from_stanza(el): + """ + Parse a Conference element into a Bookmark object + """ + jid = el['jid'] + autojoin = el['autojoin'] + password = el['password'] + nick = el['nick'] + name = el['name'] + return Bookmark(jid, name, autojoin, nick, password, method='remote') + +class BookmarkList(object): + + def __init__(self): + self.bookmarks = [] + preferred = config.get('use_bookmarks_method').lower() + if preferred not in ('pep', 'privatexml'): + preferred = 'privatexml' + self.preferred = preferred + self.available_storage = { + 'privatexml': False, + 'pep': False, + } + + def __getitem__(self, key): + if isinstance(key, (str, JID)): + for i in self.bookmarks: + if key == i.jid: + return i + else: + return self.bookmarks[key] + + def __in__(self, key): + if isinstance(key, (str, JID)): + for bookmark in self.bookmarks: + if bookmark.jid == key: + return True + else: + return key in self.bookmarks + return False + + def remove(self, key): + if isinstance(key, (str, JID)): + for i in self.bookmarks[:]: + if i.jid == key: + self.bookmarks.remove(i) + else: + self.bookmarks.remove(key) + + def __iter__(self): + return iter(self.bookmarks) + + def local(self): + return [bm for bm in self.bookmarks if bm.method == 'local'] + + def remote(self): + return [bm for bm in self.bookmarks if bm.method == 'remote'] + + def set(self, new): + self.bookmarks = new + + def append(self, bookmark): + bookmark_exists = self[bookmark.jid] + if not bookmark_exists: + self.bookmarks.append(bookmark) + else: + self.bookmarks.remove(bookmark_exists) + self.bookmarks.append(bookmark) + + def set_bookmarks_method(self, value): + if self.available_storage.get(value): + self.preferred = value + config.set_and_save('use_bookmarks_method', value) + + def save_remote(self, xmpp, callback): + """Save the remote bookmarks.""" + if not any(self.available_storage.values()): + return + method = 'xep_0049' if self.preferred == 'privatexml' else 'xep_0223' + + if method: + xmpp.plugin['xep_0048'].set_bookmarks(stanza_storage(self.bookmarks), + method=method, + callback=callback) + def save_local(self): + """Save the local bookmarks.""" + local = ''.join(bookmark.local() for bookmark in self if bookmark.method == 'local') + config.set_and_save('rooms', local) + + def save(self, xmpp, core=None, callback=None): + """Save all the bookmarks.""" + self.save_local() + def _cb(iq): + if callback: + callback(iq) + if iq["type"] == "error" and core: + core.information('Could not save remote bookmarks.', 'Error') + elif core: + core.information('Bookmarks saved', 'Info') + if config.get('use_remote_bookmarks'): + self.save_remote(xmpp, _cb) + + def get_pep(self, xmpp, callback): + """Add the remotely stored bookmarks via pep to the list.""" + def _cb(iq): + if iq['type'] == 'result': + for conf in iq['pubsub']['items']['item']['bookmarks']['conferences']: + if isinstance(conf, URL): + continue + b = Bookmark.parse(conf) + self.append(b) + if callback: + callback(iq) + + xmpp.plugin['xep_0048'].get_bookmarks(method='xep_0223', callback=_cb) + + def get_privatexml(self, xmpp, callback): + """ + Fetch the remote bookmarks stored via privatexml. + """ + def _cb(iq): + if iq['type'] == 'result': + for conf in iq['private']['bookmarks']['conferences']: + b = Bookmark.parse(conf) + self.append(b) + if callback: + callback(iq) + + xmpp.plugin['xep_0048'].get_bookmarks(method='xep_0049', callback=_cb) + + def get_remote(self, xmpp, information, callback): + """Add the remotely stored bookmarks to the list.""" + force = config.get('force_remote_bookmarks') + if xmpp.anon or not (any(self.available_storage.values()) or force): + information('No remote bookmark storage available', 'Warning') + return + + if force and not any(self.available_storage.values()): + old_callback = callback + method = 'pep' if self.preferred == 'pep' else 'privatexml' + def new_callback(result): + if result['type'] != 'error': + self.available_storage[method] = True + old_callback(result) + else: + information('No remote bookmark storage available', 'Warning') + callback = new_callback + + if self.preferred == 'pep': + self.get_pep(xmpp, callback=callback) + else: + self.get_privatexml(xmpp, callback=callback) + + def get_local(self): + """Add the locally stored bookmarks to the list.""" + rooms = config.get('rooms') + if not rooms: + return + rooms = rooms.split(':') + for room in rooms: + jid = safeJID(room) + if jid.bare == '': + continue + if jid.resource != '': + nick = jid.resource + else: + nick = None + passwd = config.get_by_tabname('password', jid.bare, fallback=False) or None + b = Bookmark(jid.bare, autojoin=True, nick=nick, password=passwd, method='local') + self.append(b) + +def stanza_storage(bookmarks): + """Generate a <storage/> stanza with the conference elements.""" + storage = Bookmarks() + for b in (b for b in bookmarks if b.method == 'remote'): + storage.append(b.stanza()) + return storage diff --git a/poezio/common.py b/poezio/common.py new file mode 100644 index 00000000..a62c83f1 --- /dev/null +++ b/poezio/common.py @@ -0,0 +1,483 @@ +# Copyright 2010-2011 Florent Le Coz <louiz@louiz.org> +# +# This file is part of Poezio. +# +# Poezio is free software: you can redistribute it and/or modify +# it under the terms of the zlib license. See the COPYING file. + +""" +Various useful functions. +""" + +from sys import version_info +from datetime import datetime, timedelta +from slixmpp import JID, InvalidJID + +import base64 +import os +import mimetypes +import hashlib +import subprocess +import time +import string +import poezio_shlex as shlex + + +# Needed to avoid datetime.datetime.timestamp() +# on python < 3.3. Older versions do not get good dst detection. +OLD_PYTHON = (version_info.major + version_info.minor/10) < 3.3 + +ROOM_STATE_NONE = 11 +ROOM_STATE_CURRENT = 10 +ROOM_STATE_PRIVATE = 15 +ROOM_STATE_MESSAGE = 12 +ROOM_STATE_HL = 13 + +def get_base64_from_file(path): + """ + Convert the content of a file to base64 + + :param str path: The path of the file to convert. + :return: A tuple of (encoded data, mime type, sha1 hash) if + the file exists and does not exceeds the upper size limit of 16384. + :return: (None, None, error message) if it fails + :rtype: :py:class:`tuple` + + """ + if not os.path.isfile(path): + return (None, None, "File does not exist") + size = os.path.getsize(path) + if size > 16384: + return (None, None,"File is too big") + fdes = open(path, 'rb') + data = fdes.read() + encoded = base64.encodestring(data) + sha1 = hashlib.sha1(data).hexdigest() + mime_type = mimetypes.guess_type(path)[0] + return (encoded, mime_type, sha1) + +def get_output_of_command(command): + """ + Runs a command and returns its output. + + :param str command: The command to run. + :return: The output or None + :rtype: :py:class:`str` + """ + try: + return subprocess.check_output(command.split()).decode('utf-8').split('\n') + except subprocess.CalledProcessError: + return None + +def is_in_path(command, return_abs_path=False): + """ + Check if *command* is in the $PATH or not. + + :param str command: The command to be checked. + :param bool return_abs_path: Return the absolute path of the command instead + of True if the command is found. + :return: True if the command is found, the command path if the command is found + and *return_abs_path* is True, otherwise False. + + """ + for directory in os.getenv('PATH').split(os.pathsep): + try: + if command in os.listdir(directory): + if return_abs_path: + return os.path.join(directory, command) + else: + return True + except OSError: + # If the user has non directories in his path + pass + return False + +DISTRO_INFO = { + 'Arch Linux': '/etc/arch-release', + 'Aurox Linux': '/etc/aurox-release', + 'Conectiva Linux': '/etc/conectiva-release', + 'CRUX': '/usr/bin/crux', + 'Debian GNU/Linux': '/etc/debian_version', + 'Fedora Linux': '/etc/fedora-release', + 'Gentoo Linux': '/etc/gentoo-release', + 'Linux from Scratch': '/etc/lfs-release', + 'Mandrake Linux': '/etc/mandrake-release', + 'Slackware Linux': '/etc/slackware-version', + 'Solaris/Sparc': '/etc/release', + 'Source Mage': '/etc/sourcemage_version', + 'SUSE Linux': '/etc/SuSE-release', + 'Sun JDS': '/etc/sun-release', + 'PLD Linux': '/etc/pld-release', + 'Yellow Dog Linux': '/etc/yellowdog-release', + # many distros use the /etc/redhat-release for compatibility + # so Redhat is the last + 'Redhat Linux': '/etc/redhat-release' +} + +def get_os_info(): + """ + Returns a detailed and well formated string containing + informations about the operating system + + :rtype: str + """ + if os.name == 'posix': + executable = 'lsb_release' + params = ' --description --codename --release --short' + full_path_to_executable = is_in_path(executable, return_abs_path = True) + if full_path_to_executable: + command = executable + params + process = subprocess.Popen([command], shell=True, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + close_fds=True) + process.wait() + output = process.stdout.readline().decode('utf-8').strip() + # some distros put n/a in places, so remove those + output = output.replace('n/a', '').replace('N/A', '') + return output + + # lsb_release executable not available, so parse files + for distro_name in DISTRO_INFO: + path_to_file = DISTRO_INFO[distro_name] + if os.path.exists(path_to_file): + if os.access(path_to_file, os.X_OK): + # the file is executable (f.e. CRUX) + # yes, then run it and get the first line of output. + text = get_output_of_command(path_to_file)[0] + else: + fdes = open(path_to_file, encoding='utf-8') + text = fdes.readline().strip() # get only first line + fdes.close() + if path_to_file.endswith('version'): + # sourcemage_version and slackware-version files + # have all the info we need (name and version of distro) + if not os.path.basename(path_to_file).startswith( + 'sourcemage') or not\ + os.path.basename(path_to_file).startswith('slackware'): + text = distro_name + ' ' + text + elif path_to_file.endswith('aurox-release') or \ + path_to_file.endswith('arch-release'): + # file doesn't have version + text = distro_name + elif path_to_file.endswith('lfs-release'): + # file just has version + text = distro_name + ' ' + text + os_info = text.replace('\n', '') + return os_info + + # our last chance, ask uname and strip it + uname_output = get_output_of_command('uname -sr') + if uname_output is not None: + os_info = uname_output[0] # only first line + return os_info + os_info = 'N/A' + return os_info + +def datetime_tuple(timestamp): + """ + Convert a timestamp using strptime and the format: %Y%m%dT%H:%M:%S. + + Because various datetime formats are used, the following exceptions + are handled: + + * Optional milliseconds appened to the string are removed + * Optional Z (that means UTC) appened to the string are removed + * XEP-082 datetime strings have all '-' chars removed to meet the above format. + + :param str timestamp: The string containing the formatted date. + :return: The date. + :rtype: :py:class:`datetime.datetime` + """ + timestamp = timestamp.replace('-', '', 2).replace(':', '') + date = timestamp[:15] + tz_msg = timestamp[15:] + try: + ret = datetime.strptime(date, '%Y%m%dT%H%M%S') + except Exception: + ret = datetime.now() + # add the message timezone if any + try: + if tz_msg and tz_msg != 'Z': + tz_mod = -1 if tz_msg[0] == '-' else 1 + tz_msg = time.strptime(tz_msg[1:], '%H%M') + tz_msg = tz_msg.tm_hour * 3600 + tz_msg.tm_min * 60 + tz_msg = timedelta(seconds=tz_mod * tz_msg) + ret -= tz_msg + except Exception: + pass # ignore if we got a badly-formatted offset + # convert UTC to local time, with DST etc. + if time.daylight and time.localtime().tm_isdst: + tz = timedelta(seconds=-time.altzone) + else: + tz = timedelta(seconds=-time.timezone) + ret += tz + return ret + +def get_utc_time(local_time=None): + """ + Get the current UTC time + + :param datetime local_time: The current local time + :return: The current UTC time + """ + if local_time is None: + local_time = datetime.now() + isdst = time.localtime().tm_isdst + else: + if OLD_PYTHON: + isdst = time.localtime(int(local_time.strftime("%s"))).tm_isdst + else: + isdst = time.localtime(int(local_time.timestamp())).tm_isdst + + if time.daylight and isdst: + tz = timedelta(seconds=time.altzone) + else: + tz = timedelta(seconds=time.timezone) + + utc_time = local_time + tz + + return utc_time + +def get_local_time(utc_time): + """ + Get the local time from an UTC time + """ + if OLD_PYTHON: + isdst = time.localtime(int(utc_time.strftime("%s"))).tm_isdst + else: + isdst = time.localtime(int(utc_time.timestamp())).tm_isdst + + if time.daylight and isdst: + tz = timedelta(seconds=time.altzone) + else: + tz = timedelta(seconds=time.timezone) + + local_time = utc_time - tz + + return local_time + +def find_delayed_tag(message): + """ + Check if a message is delayed or not. + + :param slixmpp.Message message: The message to check. + :return: A tuple containing (True, the datetime) or (False, None) + :rtype: :py:class:`tuple` + """ + + delay_tag = message.find('{urn:xmpp:delay}delay') + if delay_tag is not None: + delayed = True + date = datetime_tuple(delay_tag.attrib['stamp']) + else: + # We support the OLD and deprecated XEP: http://xmpp.org/extensions/xep-0091.html + # But it sucks, please, Jabber servers, don't do this :( + delay_tag = message.find('{jabber:x:delay}x') + if delay_tag is not None: + delayed = True + date = datetime_tuple(delay_tag.attrib['stamp']) + else: + delayed = False + date = None + return (delayed, date) + +def shell_split(st): + """ + Split a string correctly according to the quotes + around the elements. + + :param str st: The string to split. + :return: A list of the different of the string. + :rtype: :py:class:`list` + + >>> shell_split('"sdf 1" "toto 2"') + ['sdf 1', 'toto 2'] + """ + sh = shlex.shlex(st) + ret = [] + w = sh.get_token() + while w and w[2] is not None: + ret.append(w[2]) + if w[1] == len(st): + return ret + w = sh.get_token() + return ret + +def find_argument(pos, text, quoted=True): + """ + Split an input into a list of arguments, return the number of the + argument selected by pos. + + If the position searched is outside the string, or in a space between words, + then it will return the position of an hypothetical new argument. + + See the doctests of the two methods for example behaviors. + + :param int pos: The position to search. + :param str text: The text to analyze. + :param quoted: Whether to take quotes into account or not. + :rtype: int + """ + if quoted: + return find_argument_quoted(pos, text) + else: + return find_argument_unquoted(pos, text) + +def find_argument_quoted(pos, text): + """ + Get the number of the argument at position pos in + a string with possibly quoted text. + """ + sh = shlex.shlex(text) + count = -1 + w = sh.get_token() + while w and w[2] is not None: + count += 1 + if w[0] <= pos < w[1]: + return count + w = sh.get_token() + + return count + 1 + +def find_argument_unquoted(pos, text): + """ + Get the number of the argument at position pos in + a string without interpreting quotes. + """ + ret = text.split() + search = 0 + argnum = 0 + for i, elem in enumerate(ret): + elem_start = text.find(elem, search) + elem_end = elem_start + len(elem) + search = elem_end + if elem_start <= pos < elem_end: + return i + argnum = i + return argnum + 1 + +def parse_str_to_secs(duration=''): + """ + Parse a string of with a number of d, h, m, s. + + :param str duration: The formatted string. + :return: The number of seconds represented by the string + :rtype: :py:class:`int` + + >>> parse_str_to_secs("1d3m1h") + 90180 + """ + values = {'s': 1, 'm': 60, 'h': 3600, 'd': 86400} + result = 0 + tmp = '0' + for char in duration: + if char in string.digits: + tmp += char + elif char in values: + tmp_i = int(tmp) + result += tmp_i * values[char] + tmp = '0' + else: + return 0 + if tmp != '0': + result += int(tmp) + return result + +def parse_secs_to_str(duration=0): + """ + Do the reverse operation of :py:func:`parse_str_to_secs`. + + Parse a number of seconds to a human-readable string. + The string has the form XdXhXmXs. 0 units are removed. + + :param int duration: The duration, in seconds. + :return: A formatted string containing the duration. + :rtype: :py:class:`str` + + >>> parse_secs_to_str(3601) + '1h1s' + """ + secs, mins, hours, days = 0, 0, 0, 0 + result = '' + secs = duration % 60 + mins = (duration % 3600) // 60 + hours = (duration % 86400) // 3600 + days = duration // 86400 + + result += '%sd' % days if days else '' + result += '%sh' % hours if hours else '' + result += '%sm' % mins if mins else '' + result += '%ss' % secs if secs else '' + if not result: + result = '0s' + return result + +def format_tune_string(infos): + """ + Contruct a string from a dict created from an "User tune" event. + + :param dict infos: The informations + :return: The formatted string + :rtype: :py:class:`str` + """ + elems = [] + track = infos.get('track') + if track: + elems.append(track) + title = infos.get('title') + if title: + elems.append(title) + else: + elems.append('Unknown title') + elems.append('-') + artist = infos.get('artist') + if artist: + elems.append(artist) + else: + elems.append('Unknown artist') + + rating = infos.get('rating') + if rating: + elems.append('[ ' + rating + '/10' + ' ]') + length = infos.get('length') + if length: + length = int(length) + secs = length % 60 + mins = length // 60 + secs = str(secs).zfill(2) + mins = str(mins).zfill(2) + elems.append('[' + mins + ':' + secs + ']') + return ' '.join(elems) + +def format_gaming_string(infos): + """ + Construct a string from a dict containing the "user gaming" + informations. + (for now, only use address and name) + + :param dict infos: The informations + :returns: The formatted string + :rtype: :py:class:`str` + """ + name = infos.get('name') + if not name: + return '' + + server_address = infos.get('server_address') + if server_address: + return '%s on %s' % (name, server_address) + return name + +def safeJID(*args, **kwargs): + """ + Construct a :py:class:`slixmpp.JID` object from a string. + + Used to avoid tracebacks during is stringprep fails + (fall back to a JID with an empty string). + """ + try: + return JID(*args, **kwargs) + except InvalidJID: + return JID('') + diff --git a/poezio/config.py b/poezio/config.py new file mode 100644 index 00000000..7f0c75f6 --- /dev/null +++ b/poezio/config.py @@ -0,0 +1,685 @@ +""" +Defines the global config instance, used to get or set (and save) values +from/to the config file. + +This module has the particularity that some imports and global variables +are delayed because it would mean doing an incomplete setup of the python +loggers. + +TODO: get http://bugs.python.org/issue1410680 fixed, one day, in order +to remove our ugly custom I/O methods. +""" + +DEFSECTION = "Poezio" + +import logging.config +import os +import stat +import sys +import pkg_resources + +from configparser import RawConfigParser, NoOptionError, NoSectionError +from os import environ, makedirs, path, remove +from shutil import copy2 +from args import parse_args + +DEFAULT_CONFIG = { + 'Poezio': { + 'ack_message_receipts': True, + 'add_space_after_completion': True, + 'after_completion': ',', + 'alternative_nickname': '', + 'auto_reconnect': True, + 'autorejoin_delay': '5', + 'autorejoin': False, + 'beep_on': 'highlight private invite', + 'ca_cert_path': '', + 'certificate': '', + 'certfile': '', + 'ciphers': 'HIGH+kEDH:HIGH+kEECDH:HIGH:!PSK:!SRP:!3DES:!aNULL', + 'connection_check_interval': 60, + 'connection_timeout_delay': 10, + 'create_gaps': False, + 'custom_host': '', + 'custom_port': '', + 'default_nick': '', + 'deterministic_nick_colors': True, + 'nick_color_aliases': True, + 'display_activity_notifications': False, + 'display_gaming_notifications': False, + 'display_mood_notifications': False, + 'display_tune_notifications': False, + 'display_user_color_in_join_part': True, + 'enable_carbons': True, + 'enable_user_activity': True, + 'enable_user_gaming': True, + 'enable_user_mood': True, + 'enable_user_nick': True, + 'enable_user_tune': True, + 'enable_vertical_tab_list': False, + 'enable_xhtml_im': True, + 'eval_password': '', + 'exec_remote': False, + 'extract_inline_images': True, + 'filter_info_messages': '', + 'force_encryption': True, + 'force_remote_bookmarks': False, + 'go_to_previous_tab_on_alt_number': False, + 'group_corrections': True, + 'hide_exit_join': -1, + 'hide_status_change': 120, + 'hide_user_list': False, + 'highlight_on': '', + 'ignore_certificate': False, + 'ignore_private': False, + 'information_buffer_popup_on': 'error roster warning help info', + 'jid': '', + 'keyfile': '', + 'lang': 'en', + 'lazy_resize': True, + 'load_log': 10, + 'log_dir': '', + 'log_errors': True, + 'max_lines_in_memory': 2048, + 'max_messages_in_memory': 2048, + 'max_nick_length': 25, + 'muc_history_length': 50, + 'notify_messages': True, + 'open_all_bookmarks': False, + 'password': '', + 'plugins_autoload': '', + 'plugins_conf_dir': '', + 'plugins_dir': '', + 'popup_time': 4, + 'private_auto_response': '', + 'remote_fifo_path': './', + 'request_message_receipts': True, + 'resource': '', + 'rooms': '', + 'roster_group_sort': 'name', + 'roster_show_offline': False, + 'roster_sort': 'jid:show', + 'save_status': True, + 'self_ping_delay': 0, + 'send_chat_states': True, + 'send_initial_presence': True, + 'send_os_info': True, + 'send_poezio_info': True, + 'send_time': True, + 'separate_history': False, + 'server': 'anon.jeproteste.info', + 'show_composing_tabs': 'direct', + 'show_inactive_tabs': True, + 'show_jid_in_conversations': True, + 'show_muc_jid': True, + 'show_roster_jids': True, + 'show_roster_subscriptions': '', + 'show_s2s_errors': True, + 'show_tab_names': False, + 'show_tab_numbers': True, + 'show_timestamps': True, + 'show_useless_separator': True, + 'status': '', + 'status_message': '', + 'theme': 'default', + 'themes_dir': '', + 'tmp_image_dir': '', + 'use_bookmarks_method': '', + 'use_log': False, + 'use_remote_bookmarks': True, + 'user_list_sort': 'desc', + 'use_tab_nicks': True, + 'vertical_tab_list_size': 20, + 'vertical_tab_list_sort': 'desc', + 'whitespace_interval': 300, + 'words': '' + }, + 'bindings': { + 'M-i': '^I' + }, + 'var': { + 'folded_roster_groups': '', + 'info_win_height': 2 + }, + 'muc_colors': { + } +} + +class Config(RawConfigParser): + """ + load/save the config to a file + """ + def __init__(self, file_name, default=None): + RawConfigParser.__init__(self, None) + # make the options case sensitive + self.optionxform = str + self.file_name = file_name + self.read_file() + self.default = default + + def read_file(self): + try: + RawConfigParser.read(self, self.file_name, encoding='utf-8') + except TypeError: # python < 3.2 sucks + RawConfigParser.read(self, self.file_name) + # Check config integrity and fix it if it’s wrong + # only when the object is the main config + if self.__class__ is Config: + for section in ('bindings', 'var'): + if not self.has_section(section): + self.add_section(section) + + def get(self, option, default=None, section=DEFSECTION): + """ + get a value from the config but return + a default value if it is not found + The type of default defines the type + returned + """ + if default is None: + if self.default: + default = self.default.get(section, {}).get(option) + else: + default = '' + + try: + if type(default) == int: + res = self.getint(option, section) + elif type(default) == float: + res = self.getfloat(option, section) + elif type(default) == bool: + res = self.getboolean(option, section) + else: + res = self.getstr(option, section) + except (NoOptionError, NoSectionError, ValueError, AttributeError): + return default + + if res is None: + return default + return res + + def get_by_tabname(self, option, tabname, + fallback=True, fallback_server=True, default=''): + """ + Try to get the value for the option. First we look in + a section named `tabname`, if the option is not present + in the section, we search for the global option if fallback is + True. And we return `default` as a fallback as a last resort. + """ + if self.default and (not default) and fallback: + default = self.default.get(DEFSECTION, {}).get(option, '') + if tabname in self.sections(): + if option in self.options(tabname): + # We go the tab-specific option + return self.get(option, default, tabname) + if fallback_server: + return self.get_by_servname(tabname, option, default, fallback) + if fallback: + # We fallback to the global option + return self.get(option, default) + return default + + def get_by_servname(self, jid, option, default, fallback=True): + """ + Try to get the value of an option for a server + """ + server = safeJID(jid).server + if server: + server = '@' + server + if server in self.sections() and option in self.options(server): + return self.get(option, default, server) + if fallback: + return self.get(option, default) + return default + + + def __get(self, option, section=DEFSECTION, **kwargs): + """ + facility for RawConfigParser.get + """ + return RawConfigParser.get(self, section, option, **kwargs) + + def _get(self, section, conv, option, **kwargs): + """ + Redirects RawConfigParser._get + """ + return conv(self.__get(option, section, **kwargs)) + + def getstr(self, option, section=DEFSECTION): + """ + get a value and returns it as a string + """ + return self.__get(option, section) + + def getint(self, option, section=DEFSECTION): + """ + get a value and returns it as an int + """ + return RawConfigParser.getint(self, section, option) + + def getfloat(self, option, section=DEFSECTION): + """ + get a value and returns it as a float + """ + return RawConfigParser.getfloat(self, section, option) + + def getboolean(self, option, section=DEFSECTION): + """ + get a value and returns it as a boolean + """ + return RawConfigParser.getboolean(self, section, option) + + def write_in_file(self, section, option, value): + """ + Our own way to save write the value in the file + Just find the right section, and then find the + right option, and edit it. + """ + result = self._parse_file() + if not result: + return False + else: + sections, result_lines = result + + if not section in sections: + result_lines.append('[%s]' % section) + result_lines.append('%s = %s' % (option, value)) + else: + begin, end = sections[section] + pos = find_line(result_lines, begin, end, option) + + if pos is -1: + result_lines.insert(end, '%s = %s' % (option, value)) + else: + result_lines[pos] = '%s = %s' % (option, value) + + return self._write_file(result_lines) + + def remove_in_file(self, section, option): + """ + Our own way to remove an option from the file. + """ + result = self._parse_file() + if not result: + return False + else: + sections, result_lines = result + + if not section in sections: + log.error('Tried to remove the option %s from a non-' + 'existing section (%s)', option, section) + return True + else: + begin, end = sections[section] + pos = find_line(result_lines, begin, end, option) + + if pos is -1: + log.error('Tried to remove a non-existing option %s' + ' from section %s', option, section) + return True + else: + del result_lines[pos] + + return self._write_file(result_lines) + + def _write_file(self, lines): + """ + Write the config file, write to a temporary file + before copying it to the final destination + """ + try: + prefix, file = path.split(self.file_name) + filename = path.join(prefix, '.%s.tmp' % file) + fd = os.fdopen( + os.open( + filename, + os.O_WRONLY | os.O_CREAT, + 0o600), + 'w') + for line in lines: + fd.write('%s\n' % line) + fd.close() + copy2(filename, self.file_name) + remove(filename) + except: + success = False + log.error('Unable to save the config file.', exc_info=True) + else: + success = True + return success + + def _parse_file(self): + """ + Parse the config file and return the list of sections with + their start and end positions, and the lines in the file. + + Duplicate sections are preserved but ignored for the parsing. + + Returns an empty tuple if reading fails + """ + if file_ok(self.file_name): + try: + with open(self.file_name, 'r', encoding='utf-8') as df: + lines_before = [line.strip() for line in df] + except: + log.error('Unable to read the config file %s', + self.file_name, + exc_info=True) + return tuple() + else: + lines_before = [] + + sections = {} + duplicate_section = False + current_section = '' + current_line = 0 + + for line in lines_before: + if line.startswith('['): + if not duplicate_section and current_section: + sections[current_section][1] = current_line + + duplicate_section = False + current_section = line[1:-1] + + if current_section in sections: + log.error('Error while reading the configuration file,' + ' skipping until next section') + duplicate_section = True + else: + sections[current_section] = [current_line, current_line] + + current_line += 1 + if not duplicate_section and current_section: + sections[current_section][1] = current_line + + return (sections, lines_before) + + def set_and_save(self, option, value, section=DEFSECTION): + """ + set the value in the configuration then save it + to the file + """ + # Special case for a 'toggle' value. We take the current value + # and set the opposite. Warning if the no current value exists + # or it is not a bool. + if value == "toggle": + current = self.get(option, "", section) + if isinstance(current, bool): + value = str(not current) + else: + if current.lower() == "false": + value = "true" + elif current.lower() == "true": + value = "false" + else: + return ('Could not toggle option: %s.' + ' Current value is %s.' % + (option, current or "empty"), + 'Warning') + if self.has_section(section): + RawConfigParser.set(self, section, option, value) + else: + self.add_section(section) + RawConfigParser.set(self, section, option, value) + if not self.write_in_file(section, option, value): + return ('Unable to write in the config file', 'Error') + return ("%s=%s" % (option, value), 'Info') + + def remove_and_save(self, option, section=DEFSECTION): + """ + Remove an option and then save it the config file + """ + if self.has_section(section): + RawConfigParser.remove_option(self, section, option) + if not self.remove_in_file(section, option): + return ('Unable to save the config file', 'Error') + return ('Option %s deleted' % option, 'Info') + + def silent_set(self, option, value, section=DEFSECTION): + """ + Set a value, save, and return True on success and False on failure + """ + if self.has_section(section): + RawConfigParser.set(self, section, option, value) + else: + self.add_section(section) + RawConfigParser.set(self, section, option, value) + return self.write_in_file(section, option, value) + + def set(self, option, value, section=DEFSECTION): + """ + Set the value of an option temporarily + """ + try: + RawConfigParser.set(self, section, option, value) + except NoSectionError: + pass + + def to_dict(self): + """ + Returns a dict of the form {section: {option: value, option: value}, …} + """ + res = {} + for section in self.sections(): + res[section] = {} + for option in self.options(section): + res[section][option] = self.get(option, "", section) + return res + + +def find_line(lines, start, end, option): + """ + Get the number of the line containing the option in the + relevant part of the config file. + + Returns -1 if the option isn’t found + """ + current = start + for line in lines[start:end]: + if (line.startswith('%s ' % option) or + line.startswith('%s=' % option)): + return current + current += 1 + return -1 + +def file_ok(filepath): + """ + Returns True if the file exists and is readable and writeable, + False otherwise. + """ + val = path.exists(filepath) + val &= os.access(filepath, os.R_OK | os.W_OK) + return bool(val) + +def check_create_config_dir(): + """ + create the configuration directory if it doesn't exist + """ + CONFIG_HOME = environ.get("XDG_CONFIG_HOME") + if not CONFIG_HOME: + CONFIG_HOME = path.join(environ.get('HOME'), '.config') + CONFIG_PATH = path.join(CONFIG_HOME, 'poezio') + + try: + makedirs(CONFIG_PATH) + except OSError: + pass + return CONFIG_PATH + +def check_create_cache_dir(): + """ + create the cache directory if it doesn't exist + also create the subdirectories + """ + global CACHE_DIR + CACHE_HOME = environ.get("XDG_CACHE_HOME") + if not CACHE_HOME: + CACHE_HOME = path.join(environ.get('HOME'), '.cache') + CACHE_DIR = path.join(CACHE_HOME, 'poezio') + + try: + makedirs(CACHE_DIR) + makedirs(path.join(CACHE_DIR, 'images')) + except OSError: + pass + +def check_config(): + """ + Check the config file and print results + """ + result = {'missing': [], 'changed': []} + for option in DEFAULT_CONFIG['Poezio']: + value = config.get(option) + if value != DEFAULT_CONFIG['Poezio'][option]: + result['changed'].append((option, value, DEFAULT_CONFIG['Poezio'][option])) + else: + value = config.get(option, default='') + upper = value.upper() + default = str(DEFAULT_CONFIG['Poezio'][option]).upper() + if upper != default: + result['missing'].append(option) + + result['changed'].sort(key=lambda x: x[0]) + result['missing'].sort() + if result['changed']: + print('\033[1mOptions changed from the default configuration:\033[0m\n') + for option, new_value, default in result['changed']: + print(' \033[1m%s\033[0m = \033[33m%s\033[0m (default: \033[32m%s\033[0m)' % (option, new_value, default)) + + if result['missing']: + print('\n\033[1mMissing options:\033[0m (the defaults are used)\n') + for option in result['missing']: + print(' \033[31m%s\033[0m' % option) + +def run_cmdline_args(CONFIG_PATH): + "Parse the command line arguments" + global options + options = parse_args(CONFIG_PATH) + + # Copy a default file if none exists + if not path.isfile(options.filename): + default = path.join(path.dirname(__file__), '../data/default_config.cfg') + other = pkg_resources.resource_filename('poezio', 'default_config.cfg') + if path.isfile(default): + copy2(default, options.filename) + elif path.isfile(other): + copy2(other, options.filename) + + # Inside the nixstore and possibly other distributions, the reference + # file is readonly, so is the copy. + # Make it writable by the user who just created it. + if os.path.exists(options.filename): + os.chmod(options.filename, + os.stat(options.filename).st_mode | stat.S_IWUSR) + + global firstrun + firstrun = True + +def create_global_config(): + "Create the global config object, or crash" + try: + global config + config = Config(options.filename, DEFAULT_CONFIG) + except: + import traceback + sys.stderr.write('Poezio was unable to read or' + ' parse the config file.\n') + traceback.print_exc(limit=0) + sys.exit(1) + +def check_create_log_dir(): + "Create the poezio logging directory if it doesn’t exist" + global LOG_DIR + LOG_DIR = config.get('log_dir') + + if not LOG_DIR: + + data_dir = environ.get('XDG_DATA_HOME') + if not data_dir: + home = environ.get('HOME') + data_dir = path.join(home, '.local', 'share') + + LOG_DIR = path.join(data_dir, 'poezio', 'logs') + + LOG_DIR = path.expanduser(LOG_DIR) + + try: + makedirs(LOG_DIR) + except: + pass + +def setup_logging(): + "Change the logging config according to the cmdline options and config" + if config.get('log_errors'): + LOGGING_CONFIG['root']['handlers'].append('error') + LOGGING_CONFIG['handlers']['error'] = { + 'level': 'ERROR', + 'class': 'logging.FileHandler', + 'filename': path.join(LOG_DIR, 'errors.log'), + 'formatter': 'simple', + } + + if options.debug: + LOGGING_CONFIG['root']['handlers'].append('debug') + LOGGING_CONFIG['handlers']['debug'] = { + 'level':'DEBUG', + 'class':'logging.FileHandler', + 'filename': options.debug, + 'formatter': 'simple', + } + + + if LOGGING_CONFIG['root']['handlers']: + logging.config.dictConfig(LOGGING_CONFIG) + else: + logging.basicConfig(level=logging.CRITICAL) + + global log + log = logging.getLogger(__name__) + +def post_logging_setup(): + # common imports slixmpp, which creates then its loggers, so + # it needs to be after logger configuration + from common import safeJID as JID + global safeJID + safeJID = JID + +LOGGING_CONFIG = { + 'version': 1, + 'disable_existing_loggers': True, + 'formatters': { + 'simple': { + 'format': '%(asctime)s %(levelname)s:%(module)s:%(message)s' + } + }, + 'handlers': { + }, + 'root': { + 'handlers': [], + 'propagate': True, + 'level': 'DEBUG', + } +} + +# True if this is the first run, in this case we will display +# some help in the info buffer +firstrun = False + +# Global config object. Is setup in poezio.py +config = None + +# The logger object for this module +log = None + +# The command-line options +options = None + +# delayed import from common.py +safeJID = None + +# the global log dir +LOG_DIR = '' + +# the global cache dir +CACHE_DIR = '' diff --git a/poezio/connection.py b/poezio/connection.py new file mode 100644 index 00000000..c4cc8b6b --- /dev/null +++ b/poezio/connection.py @@ -0,0 +1,223 @@ +# Copyright 2010-2011 Florent Le Coz <louiz@louiz.org> +# +# This file is part of Poezio. +# +# Poezio is free software: you can redistribute it and/or modify +# it under the terms of the zlib license. See the COPYING file. + +""" +Defines the Connection class +""" + +import logging +log = logging.getLogger(__name__) + + +import getpass +import subprocess +import sys + +import slixmpp +from slixmpp.plugins.xep_0184 import XEP_0184 + +import common +import fixes +from common import safeJID +from config import config, options + +class Connection(slixmpp.ClientXMPP): + """ + Receives everything from Jabber and emits the + appropriate signals + """ + __init = False + def __init__(self): + resource = config.get('resource') + + keyfile = config.get('keyfile') + certfile = config.get('certfile') + + if config.get('jid'): + # Field used to know if we are anonymous or not. + # many features will be handled differently + # depending on this setting + self.anon = False + jid = '%s' % config.get('jid') + if resource: + jid = '%s/%s'% (jid, resource) + password = config.get('password') + eval_password = config.get('eval_password') + if not password and not eval_password and not (keyfile and certfile): + password = getpass.getpass() + elif not password and not (keyfile and certfile): + sys.stderr.write("No password or certificates provided, using the eval_password command.\n") + process = subprocess.Popen(['sh', '-c', eval_password], stdin=subprocess.PIPE, + stdout=subprocess.PIPE, close_fds=True) + code = process.wait() + if code != 0: + sys.stderr.write('The eval_password command (%s) returned a ' + 'nonzero status code: %s.\n' % (eval_password, code)) + sys.stderr.write('Poezio will now exit\n') + sys.exit(code) + password = process.stdout.readline().decode('utf-8').strip('\n') + else: # anonymous auth + self.anon = True + jid = config.get('server') + if resource: + jid = '%s/%s' % (jid, resource) + password = None + jid = safeJID(jid) + # TODO: use the system language + slixmpp.ClientXMPP.__init__(self, jid, password, + lang=config.get('lang')) + + force_encryption = config.get('force_encryption') + if force_encryption: + self['feature_mechanisms'].unencrypted_plain = False + self['feature_mechanisms'].unencrypted_digest = False + self['feature_mechanisms'].unencrypted_cram = False + self['feature_mechanisms'].unencrypted_scram = False + + self.keyfile = config.get('keyfile') + self.certfile = config.get('certfile') + if keyfile and not certfile: + log.error('keyfile is present in configuration file without certfile') + elif certfile and not keyfile: + log.error('certfile is present in configuration file without keyfile') + + self.core = None + self.auto_reconnect = config.get('auto_reconnect') + self.reconnect_max_attempts = 0 + self.auto_authorize = None + # prosody defaults, lowest is AES128-SHA, it should be a minimum + # for anything that came out after 2002 + self.ciphers = config.get('ciphers', + 'HIGH+kEDH:HIGH+kEECDH:HIGH:!PSK' + ':!SRP:!3DES:!aNULL') + self.ca_certs = config.get('ca_cert_path') or None + interval = config.get('whitespace_interval') + if int(interval) > 0: + self.whitespace_keepalive_interval = int(interval) + else: + self.whitespace_keepalive = False + self.register_plugin('xep_0004') + self.register_plugin('xep_0012') + self.register_plugin('xep_0030') + self.register_plugin('xep_0045') + self.register_plugin('xep_0048') + self.register_plugin('xep_0050') + self.register_plugin('xep_0054') + self.register_plugin('xep_0060') + self.register_plugin('xep_0066') + self.register_plugin('xep_0071') + self.register_plugin('xep_0077') + self.plugin['xep_0077'].create_account = False + self.register_plugin('xep_0085') + self.register_plugin('xep_0115') + + # monkey-patch xep_0184 to avoid requesting receipts for messages + # without a body + XEP_0184._filter_add_receipt_request = fixes._filter_add_receipt_request + self.register_plugin('xep_0184') + self.plugin['xep_0184'].auto_ack = config.get('ack_message_receipts') + self.plugin['xep_0184'].auto_request = config.get('request_message_receipts') + + self.register_plugin('xep_0191') + self.register_plugin('xep_0198') + self.register_plugin('xep_0199') + + if config.get('enable_user_tune'): + self.register_plugin('xep_0118') + + if config.get('enable_user_nick'): + self.register_plugin('xep_0172') + + if config.get('enable_user_mood'): + self.register_plugin('xep_0107') + + if config.get('enable_user_activity'): + self.register_plugin('xep_0108') + + if config.get('enable_user_gaming'): + self.register_plugin('xep_0196') + + if config.get('send_poezio_info'): + info = {'name':'poezio', + 'version': options.version} + if config.get('send_os_info'): + info['os'] = common.get_os_info() + self.plugin['xep_0030'].set_identities( + identities=set([('client', 'pc', None, 'Poezio')])) + else: + info = {'name': '', 'version': ''} + self.plugin['xep_0030'].set_identities( + identities=set([('client', 'pc', None, '')])) + self.register_plugin('xep_0092', pconfig=info) + if config.get('send_time'): + self.register_plugin('xep_0202') + self.register_plugin('xep_0224') + self.register_plugin('xep_0231') + self.register_plugin('xep_0249') + self.register_plugin('xep_0257') + self.register_plugin('xep_0280') + self.register_plugin('xep_0297') + self.register_plugin('xep_0308') + self.register_plugin('xep_0319') + self.register_plugin('xep_0334') + self.register_plugin('xep_0352') + self.init_plugins() + + def set_keepalive_values(self, option=None, value=None): + """ + Called after the XMPP session has been started, or triggered when one of + "connection_timeout_delay" and "connection_check_interval" options + is changed. Unload and reload the ping plugin, with the new values. + """ + if not self.is_connected(): + # Happens when we change the value with /set while we are not + # connected. Do nothing in that case + return + ping_interval = config.get('connection_check_interval') + timeout_delay = config.get('connection_timeout_delay') + if timeout_delay <= 0: + # We help the stupid user (with a delay of 0, poezio will try to + # reconnect immediately because the timeout is immediately + # passed) + # 1 second is short, but, well + timeout_delay = 1 + self.plugin['xep_0199'].disable_keepalive() + # If the ping_interval is 0 or less, we just disable the keepalive + if ping_interval > 0: + self.plugin['xep_0199'].enable_keepalive(ping_interval, + timeout_delay) + + def start(self): + """ + Connect and process events. + """ + custom_host = config.get('custom_host') + custom_port = config.get('custom_port', 5222) + if custom_port == -1: + custom_port = 5222 + if custom_host: + self.connect((custom_host, custom_port)) + elif custom_port != 5222 and custom_port != -1: + self.connect((self.boundjid.host, custom_port)) + else: + self.connect() + + def send_raw(self, data): + """ + Overrides XMLStream.send_raw, with an event added + """ + if self.core: + self.core.outgoing_stanza(data) + slixmpp.ClientXMPP.send_raw(self, data) + +class MatchAll(slixmpp.xmlstream.matcher.base.MatcherBase): + """ + Callback to retrieve all the stanzas for the XML tab + """ + def match(self, xml): + "match everything" + return True diff --git a/poezio/contact.py b/poezio/contact.py new file mode 100644 index 00000000..c670e5bc --- /dev/null +++ b/poezio/contact.py @@ -0,0 +1,197 @@ +# Copyright 2010-2011 Florent Le Coz <louiz@louiz.org> +# +# This file is part of Poezio. +# +# Poezio is free software: you can redistribute it and/or modify +# it under the terms of the zlib license. See the COPYING file. + +""" +Defines the Resource and Contact classes, which are used in +the roster. +""" + +import logging +log = logging.getLogger(__name__) + +from common import safeJID +from collections import defaultdict + +class Resource(object): + """ + Defines a roster item. + It's a precise resource. + """ + def __init__(self, jid, data): + """ + data: the dict to use as a source + """ + self._jid = jid # Full jid + self._data = data + + @property + def jid(self): + return self._jid + + @property + def priority(self): + return self._data.get('priority') or 0 + + @property + def presence(self): + return self._data.get('show') or '' + + show = presence + + @property + def status(self): + return self._data.get('status') or '' + + def __repr__(self): + return '<%s>' % self._jid + + def __eq__(self, value): + if not isinstance(value, Resource): + return False + return self.jid == value.jid and self._data == value._data + +class Contact(object): + """ + This a way to gather multiple resources from the same bare JID. + This class contains zero or more Resource object and useful methods + to get the resource with the highest priority, etc + """ + def __init__(self, item): + """ + item: a slixmpp RosterItem pointing to that contact + """ + self.__item = item + self.folded_states = defaultdict(lambda: True) + self._name = '' + self.error = None + self.tune = {} + self.gaming = {} + self.mood = '' + self.activity = '' + + @property + def groups(self): + """Name of the groups the contact is in""" + return self.__item['groups'] or ['none'] + + @property + def bare_jid(self): + """The bare jid of the contact""" + return self.__item.jid + + @property + def name(self): + """The name of the contact or an empty string.""" + return self.__item['name'] or self._name or '' + + @name.setter + def name(self, value): + """Set the name of the contact with user nickname""" + self._name = value + + @property + def ask(self): + if self.__item['pending_out']: + return 'asked' + + @property + def pending_in(self): + """We received a subscribe stanza from this contact.""" + return self.__item['pending_in'] + + @pending_in.setter + def pending_in(self, value): + self.__item['pending_in'] = value + + @property + def pending_out(self): + """We sent a subscribe stanza to this contact.""" + return self.__item['pending_out'] + + @pending_out.setter + def pending_out(self, value): + self.__item['pending_out'] = value + + @property + def resources(self): + """List of the available resources as Resource objects""" + return (Resource( + '%s%s' % (self.bare_jid, ('/' + key) if key else ''), + self.__item.resources[key] + ) for key in self.__item.resources.keys()) + + @property + def subscription(self): + return self.__item['subscription'] + + def __contains__(self, value): + return value in self.__item.resources or safeJID(value).resource in self.__item.resources + + def __len__(self): + """Number of resources""" + return len(self.__item.resources) + + def __bool__(self): + """This contacts exists even when he has no resources""" + return True + + def __getitem__(self, key): + """Return the corresponding Resource object, or None""" + res = safeJID(key).resource + resources = self.__item.resources + item = resources.get(res, None) or resources.get(key, None) + return Resource(key, item) if item else None + + def subscribe(self): + """Subscribe to this JID""" + self.__item.subscribe() + + def authorize(self): + """Authorize this JID""" + self.__item.authorize() + + def unauthorize(self): + """Unauthorize this JID""" + self.__item.unauthorize() + + def unsubscribe(self): + """Unsubscribe from this JID""" + self.__item.unsubscribe() + + def get(self, key, default=None): + """Same as __getitem__, but with a configurable default""" + return self[key] or default + + def get_resources(self): + """Return all resources, sorted by priority """ + compare_resources = lambda x: x.priority + return sorted(self.resources, key=compare_resources, reverse=True) + + def get_highest_priority_resource(self): + """Return the resource with the highest priority""" + resources = self.get_resources() + if resources: + return resources[0] + return None + + def folded(self, group_name='none'): + """ + Return the Folded state of a contact for this group + """ + return self.folded_states[group_name] + + def toggle_folded(self, group='none'): + """ + Fold if it's unfolded, and vice versa + """ + self.folded_states[group] = not self.folded_states[group] + + def __repr__(self): + ret = '<Contact: %s' % self.bare_jid + for resource in self.resources: + ret += '\n\t\t%s' % resource + return ret + ' />\n' diff --git a/poezio/core/__init__.py b/poezio/core/__init__.py new file mode 100644 index 00000000..6a82e2bb --- /dev/null +++ b/poezio/core/__init__.py @@ -0,0 +1,8 @@ +""" +Core class, splitted into smaller chunks +""" + +from . core import Core +from . structs import Command, Status, possible_show, DEPRECATED_ERRORS, \ + ERROR_AND_STATUS_CODES + diff --git a/poezio/core/commands.py b/poezio/core/commands.py new file mode 100644 index 00000000..a0a636c1 --- /dev/null +++ b/poezio/core/commands.py @@ -0,0 +1,999 @@ +""" +Global commands which are to be linked to the Core class +""" + +import logging + +log = logging.getLogger(__name__) + +import os +from datetime import datetime +from xml.etree import cElementTree as ET + +from slixmpp.xmlstream.stanzabase import StanzaBase +from slixmpp.xmlstream.handler import Callback +from slixmpp.xmlstream.matcher import StanzaPath + +import common +import fixes +import pep +import tabs +from bookmarks import Bookmark +from common import safeJID +from config import config, DEFAULT_CONFIG, options as config_opts +import multiuserchat as muc +from plugin import PluginConfig +from roster import roster +from theming import dump_tuple, get_theme +from decorators import command_args_parser + +from . structs import Command, possible_show + + +@command_args_parser.quoted(0, 1) +def command_help(self, args): + """ + /help [command_name] + """ + if not args: + color = dump_tuple(get_theme().COLOR_HELP_COMMANDS) + acc = [] + buff = ['Global commands:'] + for command in self.commands: + if isinstance(self.commands[command], Command): + acc.append(' \x19%s}%s\x19o - %s' % ( + color, + command, + self.commands[command].short)) + else: + acc.append(' \x19%s}%s\x19o' % (color, command)) + acc = sorted(acc) + buff.extend(acc) + acc = [] + buff.append('Tab-specific commands:') + commands = self.current_tab().commands + for command in commands: + if isinstance(commands[command], Command): + acc.append(' \x19%s}%s\x19o - %s' % ( + color, + command, + commands[command].short)) + else: + acc.append(' \x19%s}%s\x19o' % (color, command)) + acc = sorted(acc) + buff.extend(acc) + + msg = '\n'.join(buff) + msg += "\nType /help <command_name> to know what each command does" + else: + command = args[0].lstrip('/').strip() + + if command in self.current_tab().commands: + tup = self.current_tab().commands[command] + elif command in self.commands: + tup = self.commands[command] + else: + self.information('Unknown command: %s' % command, 'Error') + return + if isinstance(tup, Command): + msg = 'Usage: /%s %s\n' % (command, tup.usage) + msg += tup.desc + else: + msg = tup[1] + self.information(msg, 'Help') + +@command_args_parser.quoted(1) +def command_runkey(self, args): + """ + /runkey <key> + """ + def replace_line_breaks(key): + "replace ^J with \n" + if key == '^J': + return '\n' + return key + if args is None: + return self.command_help('runkey') + char = args[0] + func = self.key_func.get(char, None) + if func: + func() + else: + res = self.do_command(replace_line_breaks(char), False) + if res: + self.refresh_window() + +@command_args_parser.quoted(1, 1, [None]) +def command_status(self, args): + """ + /status <status> [msg] + """ + if args is None: + return self.command_help('status') + + if not args[0] in possible_show.keys(): + return self.command_help('status') + + show = possible_show[args[0]] + msg = args[1] + + pres = self.xmpp.make_presence() + if msg: + pres['status'] = msg + pres['type'] = show + self.events.trigger('send_normal_presence', pres) + pres.send() + current = self.current_tab() + is_muctab = isinstance(current, tabs.MucTab) + if is_muctab and current.joined and show in ('away', 'xa'): + current.send_chat_state('inactive') + for tab in self.tabs: + if isinstance(tab, tabs.MucTab) and tab.joined: + muc.change_show(self.xmpp, tab.name, tab.own_nick, show, msg) + if hasattr(tab, 'directed_presence'): + del tab.directed_presence + self.set_status(show, msg) + if is_muctab and current.joined and show not in ('away', 'xa'): + current.send_chat_state('active') + +@command_args_parser.quoted(1, 2, [None, None]) +def command_presence(self, args): + """ + /presence <JID> [type] [status] + """ + if args is None: + return self.command_help('presence') + + jid, type, status = args[0], args[1], args[2] + if jid == '.' and isinstance(self.current_tab(), tabs.ChatTab): + jid = self.current_tab().name + if type == 'available': + type = None + try: + pres = self.xmpp.make_presence(pto=jid, ptype=type, pstatus=status) + self.events.trigger('send_normal_presence', pres) + pres.send() + except: + self.information('Could not send directed presence', 'Error') + log.debug('Could not send directed presence to %s', jid, exc_info=True) + return + tab = self.get_tab_by_name(jid) + if tab: + if type in ('xa', 'away'): + tab.directed_presence = False + chatstate = 'inactive' + else: + tab.directed_presence = True + chatstate = 'active' + if tab == self.current_tab(): + tab.send_chat_state(chatstate, True) + if isinstance(tab, tabs.MucTab): + for private in tab.privates: + private.directed_presence = tab.directed_presence + if self.current_tab() in tab.privates: + self.current_tab().send_chat_state(chatstate, True) + +@command_args_parser.quoted(1) +def command_theme(self, args=None): + """/theme <theme name>""" + if args is None: + return self.command_help('theme') + self.command_set('theme %s' % (args[0],)) + +@command_args_parser.quoted(1) +def command_win(self, args): + """ + /win <number> + """ + if args is None: + return self.command_help('win') + + nb = args[0] + try: + nb = int(nb) + except ValueError: + pass + if self.current_tab_nb == nb: + return + self.previous_tab_nb = self.current_tab_nb + old_tab = self.current_tab() + if isinstance(nb, int): + if 0 <= nb < len(self.tabs): + if not self.tabs[nb]: + return + self.current_tab_nb = nb + else: + matchs = [] + for tab in self.tabs: + for name in tab.matching_names(): + if nb.lower() in name[1].lower(): + matchs.append((name[0], tab)) + self.current_tab_nb = tab.nb + if not matchs: + return + tab = min(matchs, key=lambda m: m[0])[1] + self.current_tab_nb = tab.nb + old_tab.on_lose_focus() + self.current_tab().on_gain_focus() + self.refresh_window() + +@command_args_parser.quoted(2) +def command_move_tab(self, args): + """ + /move_tab old_pos new_pos + """ + if args is None: + return self.command_help('move_tab') + + current_tab = self.current_tab() + if args[0] == '.': + args[0] = current_tab.nb + if args[1] == '.': + args[1] = current_tab.nb + + def get_nb_from_value(value): + "parse the cmdline to guess the tab the users wants" + ref = None + try: + ref = int(value) + except ValueError: + old_tab = None + for tab in self.tabs: + if not old_tab and value == tab.name: + old_tab = tab + if not old_tab: + self.information("Tab %s does not exist" % args[0], "Error") + return None + ref = old_tab.nb + return ref + old = get_nb_from_value(args[0]) + new = get_nb_from_value(args[1]) + if new is None or old is None: + return self.information('Unable to move the tab.', 'Info') + result = self.insert_tab(old, new) + if not result: + self.information('Unable to move the tab.', 'Info') + else: + self.current_tab_nb = self.tabs.index(current_tab) + self.refresh_window() + +@command_args_parser.quoted(0, 1) +def command_list(self, args): + """ + /list [server] + Opens a MucListTab containing the list of the room in the specified server + """ + if args is None: + return self.command_help('list') + elif args: + jid = safeJID(args[0]) + else: + if not isinstance(self.current_tab(), tabs.MucTab): + return self.information('Please provide a server', 'Error') + jid = safeJID(self.current_tab().name).server + list_tab = tabs.MucListTab(jid) + self.add_tab(list_tab, True) + cb = list_tab.on_muc_list_item_received + self.xmpp.plugin['xep_0030'].get_items(jid=jid, + callback=cb) + +@command_args_parser.quoted(1) +def command_version(self, args): + """ + /version <jid> + """ + def callback(res): + "Callback for /version" + if not res: + return self.information('Could not get the software' + ' version from %s' % jid, + 'Warning') + version = '%s is running %s version %s on %s' % ( + jid, + res.get('name') or 'an unknown software', + res.get('version') or 'unknown', + res.get('os') or 'an unknown platform') + self.information(version, 'Info') + + if args is None: + return self.command_help('version') + + jid = safeJID(args[0]) + if jid.resource or jid not in roster: + fixes.get_version(self.xmpp, jid, callback=callback) + elif jid in roster: + for resource in roster[jid].resources: + fixes.get_version(self.xmpp, resource.jid, callback=callback) + else: + fixes.get_version(self.xmpp, jid, callback=callback) + +@command_args_parser.quoted(0, 2) +def command_join(self, args): + """ + /join [room][/nick] [password] + """ + password = None + if len(args) == 0: + tab = self.current_tab() + if not isinstance(tab, (tabs.MucTab, tabs.PrivateTab)): + return + room = safeJID(tab.name).bare + nick = tab.own_nick + else: + if args[0].startswith('@'): # we try to join a server directly + server_root = True + info = safeJID(args[0][1:]) + else: + info = safeJID(args[0]) + server_root = False + if info == '' and len(args[0]) > 1 and args[0][0] == '/': + nick = args[0][1:] + elif info.resource == '': + nick = self.own_nick + else: + nick = info.resource + if info.bare == '': # happens with /join /nickname, which is OK + tab = self.current_tab() + if not isinstance(tab, tabs.MucTab): + return + room = tab.name + if nick == '': + nick = tab.own_nick + else: + room = info.bare + # no server is provided, like "/join hello": + # use the server of the current room if available + # check if the current room's name has a server + if room.find('@') == -1 and not server_root: + if isinstance(self.current_tab(), tabs.MucTab) and\ + self.current_tab().name.find('@') != -1: + domain = safeJID(self.current_tab().name).domain + room += '@%s' % domain + else: + room = args[0] + room = room.lower() + if room in self.pending_invites: + del self.pending_invites[room] + tab = self.get_tab_by_name(room, tabs.MucTab) + if tab is not None: + self.focus_tab_named(tab.name) + if tab.own_nick == nick and tab.joined: + self.information('/join: Nothing to do.', 'Info') + else: + tab.command_part('') + tab.own_nick = nick + tab.join() + + return + + if room.startswith('@'): + room = room[1:] + if len(args) == 2: # a password is provided + password = args[1] + if password is None: # try to use a saved password + password = config.get_by_tabname('password', room, fallback=False) + if tab is not None: + if password: + tab.password = password + tab.join() + else: + tab = self.open_new_room(room, nick, password=password) + tab.join() + + if tab.joined: + self.enable_private_tabs(room) + tab.state = "normal" + if tab == self.current_tab(): + tab.refresh() + self.doupdate() + +@command_args_parser.quoted(0, 2) +def command_bookmark_local(self, args): + """ + /bookmark_local [room][/nick] [password] + """ + if not args and not isinstance(self.current_tab(), tabs.MucTab): + return + password = args[1] if len(args) > 1 else None + jid = args[0] if args else None + + _add_bookmark(self, jid, True, password, 'local') + +@command_args_parser.quoted(0, 3) +def command_bookmark(self, args): + """ + /bookmark [room][/nick] [autojoin] [password] + """ + if not args and not isinstance(self.current_tab(), tabs.MucTab): + return + jid = args[0] if args else '' + password = args[2] if len(args) > 2 else None + + if not config.get('use_remote_bookmarks'): + return _add_bookmark(self, jid, True, password, 'local') + + if len(args) > 1: + autojoin = False if args[1].lower() != 'true' else True + else: + autojoin = True + + _add_bookmark(self, jid, autojoin, password, 'remote') + +def _add_bookmark(self, jid, autojoin, password, method): + nick = None + if not jid: + tab = self.current_tab() + roomname = tab.name + if tab.joined and tab.own_nick != self.own_nick: + nick = tab.own_nick + if password is None and tab.password is not None: + password = tab.password + elif jid == '*': + return _add_wildcard_bookmarks(self, method) + else: + info = safeJID(jid) + roomname, nick = info.bare, info.resource + if roomname == '': + if not isinstance(self.current_tab(), tabs.MucTab): + return + roomname = self.current_tab().name + bookmark = self.bookmarks[roomname] + if bookmark is None: + bookmark = Bookmark(roomname) + self.bookmarks.append(bookmark) + bookmark.method = method + bookmark.autojoin = autojoin + if nick: + bookmark.nick = nick + if password: + bookmark.password = password + def callback(iq): + if iq["type"] != "error": + self.information('Bookmark added.', 'Info') + else: + self.information("Could not add the bookmarks.", "Info") + self.bookmarks.save_local() + self.bookmarks.save_remote(self.xmpp, callback) + +def _add_wildcard_bookmarks(self, method): + new_bookmarks = [] + for tab in self.get_tabs(tabs.MucTab): + bookmark = self.bookmarks[tab.name] + if not bookmark: + bookmark = Bookmark(tab.name, autojoin=True, + method=method) + new_bookmarks.append(bookmark) + else: + bookmark.method = method + new_bookmarks.append(bookmark) + self.bookmarks.remove(bookmark) + new_bookmarks.extend(self.bookmarks.bookmarks) + self.bookmarks.set(new_bookmarks) + def _cb(iq): + if iq["type"] != "error": + self.information("Bookmarks saved.", "Info") + else: + self.information("Could not save the remote bookmarks.", "Info") + self.bookmarks.save_local() + self.bookmarks.save_remote(self.xmpp, _cb) + +@command_args_parser.ignored +def command_bookmarks(self): + """/bookmarks""" + tab = self.get_tab_by_name('Bookmarks', tabs.BookmarksTab) + old_tab = self.current_tab() + if tab: + self.current_tab_nb = tab.nb + else: + tab = tabs.BookmarksTab(self.bookmarks) + self.tabs.append(tab) + self.current_tab_nb = tab.nb + old_tab.on_lose_focus() + tab.on_gain_focus() + self.refresh_window() + +@command_args_parser.quoted(0, 1) +def command_remove_bookmark(self, args): + """/remove_bookmark [jid]""" + + def cb(success): + if success: + self.information('Bookmark deleted', 'Info') + else: + self.information('Error while deleting the bookmark', 'Error') + + if not args: + tab = self.current_tab() + if isinstance(tab, tabs.MucTab) and self.bookmarks[tab.name]: + self.bookmarks.remove(tab.name) + self.bookmarks.save(self.xmpp, callback=cb) + else: + self.information('No bookmark to remove', 'Info') + else: + if self.bookmarks[args[0]]: + self.bookmarks.remove(args[0]) + self.bookmarks.save(self.xmpp, callback=cb) + else: + self.information('No bookmark to remove', 'Info') + +@command_args_parser.quoted(0, 3) +def command_set(self, args): + """ + /set [module|][section] <option> [value] + """ + if args is None or len(args) == 0: + config_dict = config.to_dict() + lines = [] + theme = get_theme() + for section_name, section in config_dict.items(): + lines.append('\x19%(section_col)s}[%(section)s]\x19o' % + { + 'section': section_name, + 'section_col': dump_tuple(theme.COLOR_INFORMATION_TEXT), + }) + for option_name, option_value in section.items(): + lines.append('%s\x19%s}=\x19o%s' % (option_name, + dump_tuple(theme.COLOR_REVISIONS_MESSAGE), + option_value)) + info = ('Current options:\n%s' % '\n'.join(lines), 'Info') + elif len(args) == 1: + option = args[0] + value = config.get(option) + if value is None and '=' in option: + args = option.split('=', 1) + info = ('%s=%s' % (option, value), 'Info') + if len(args) == 2: + if '|' in args[0]: + plugin_name, section = args[0].split('|')[:2] + if not section: + section = plugin_name + option = args[1] + if not plugin_name in self.plugin_manager.plugins: + file_name = self.plugin_manager.plugins_conf_dir + file_name = os.path.join(file_name, plugin_name + '.cfg') + plugin_config = PluginConfig(file_name, plugin_name) + else: + plugin_config = self.plugin_manager.plugins[plugin_name].config + value = plugin_config.get(option, default='', section=section) + info = ('%s=%s' % (option, value), 'Info') + else: + possible_section = args[0] + if config.has_section(possible_section): + section = possible_section + option = args[1] + value = config.get(option, section=section) + info = ('%s=%s' % (option, value), 'Info') + else: + option = args[0] + value = args[1] + info = config.set_and_save(option, value) + self.trigger_configuration_change(option, value) + elif len(args) == 3: + if '|' in args[0]: + plugin_name, section = args[0].split('|')[:2] + if not section: + section = plugin_name + option = args[1] + value = args[2] + if not plugin_name in self.plugin_manager.plugins: + file_name = self.plugin_manager.plugins_conf_dir + file_name = os.path.join(file_name, plugin_name + '.cfg') + plugin_config = PluginConfig(file_name, plugin_name) + else: + plugin_config = self.plugin_manager.plugins[plugin_name].config + info = plugin_config.set_and_save(option, value, section) + else: + if args[0] == '.': + name = safeJID(self.current_tab().name).bare + if not name: + self.information('Invalid tab to use the "." argument.', + 'Error') + return + section = name + else: + section = args[0] + option = args[1] + value = args[2] + info = config.set_and_save(option, value, section) + self.trigger_configuration_change(option, value) + elif len(args) > 3: + return self.command_help('set') + self.information(*info) + +@command_args_parser.quoted(1, 2) +def command_set_default(self, args): + """ + /set_default [section] <option> + """ + if len(args) == 1: + option = args[0] + section = 'Poezio' + elif len(args) == 2: + section = args[0] + option = args[1] + else: + return self.command_help('set_default') + + default_config = DEFAULT_CONFIG.get(section, tuple()) + if option not in default_config: + info = ("Option %s has no default value" % (option), "Error") + return self.information(*info) + self.command_set('%s %s %s' % (section, option, default_config[option])) + +@command_args_parser.quoted(1) +def command_toggle(self, args): + """ + /toggle <option> + shortcut for /set <option> toggle + """ + if args is None: + return self.command_help('toggle') + + if args[0]: + self.command_set('%s toggle' % args[0]) + +@command_args_parser.quoted(1, 1) +def command_server_cycle(self, args): + """ + Do a /cycle on each room of the given server. + If none, do it on the current tab + """ + tab = self.current_tab() + message = "" + if args: + domain = args[0] + if len(args) == 2: + message = args[1] + else: + if isinstance(tab, tabs.MucTab): + domain = safeJID(tab.name).domain + else: + return self.information("No server specified", "Error") + for tab in self.get_tabs(tabs.MucTab): + if tab.name.endswith(domain): + if tab.joined: + muc.leave_groupchat(tab.core.xmpp, + tab.name, + tab.own_nick, + message) + tab.joined = False + if tab.name == domain: + self.command_join('"@%s/%s"' %(tab.name, tab.own_nick)) + else: + self.command_join('"%s/%s"' %(tab.name, tab.own_nick)) + +@command_args_parser.quoted(1) +def command_last_activity(self, args): + """ + /last_activity <jid> + """ + def callback(iq): + "Callback for the last activity" + if iq['type'] != 'result': + if iq['error']['type'] == 'auth': + self.information('You are not allowed to see the ' + 'activity of this contact.', + 'Error') + else: + self.information('Error retrieving the activity', 'Error') + return + seconds = iq['last_activity']['seconds'] + status = iq['last_activity']['status'] + from_ = iq['from'] + if not safeJID(from_).user: + msg = 'The uptime of %s is %s.' % ( + from_, + common.parse_secs_to_str(seconds)) + else: + msg = 'The last activity of %s was %s ago%s' % ( + from_, + common.parse_secs_to_str(seconds), + (' and his/her last status was %s' % status) if status else '') + self.information(msg, 'Info') + + if args is None: + return self.command_help('last_activity') + jid = safeJID(args[0]) + self.xmpp.plugin['xep_0012'].get_last_activity(jid, + callback=callback) + +@command_args_parser.quoted(0, 2) +def command_mood(self, args): + """ + /mood [<mood> [text]] + """ + if not args: + return self.xmpp.plugin['xep_0107'].stop() + + mood = args[0] + if mood not in pep.MOODS: + return self.information('%s is not a correct value for a mood.' + % mood, + 'Error') + if len(args) == 2: + text = args[1] + else: + text = None + self.xmpp.plugin['xep_0107'].publish_mood(mood, text, + callback=dumb_callback) + +@command_args_parser.quoted(0, 3) +def command_activity(self, args): + """ + /activity [<general> [specific] [text]] + """ + length = len(args) + if not length: + return self.xmpp.plugin['xep_0108'].stop() + + general = args[0] + if general not in pep.ACTIVITIES: + return self.information('%s is not a correct value for an activity' + % general, + 'Error') + specific = None + text = None + if length == 2: + if args[1] in pep.ACTIVITIES[general]: + specific = args[1] + else: + text = args[1] + elif length == 3: + specific = args[1] + text = args[2] + if specific and specific not in pep.ACTIVITIES[general]: + return self.information('%s is not a correct value ' + 'for an activity' % specific, + 'Error') + self.xmpp.plugin['xep_0108'].publish_activity(general, specific, text, + callback=dumb_callback) + +@command_args_parser.quoted(0, 2) +def command_gaming(self, args): + """ + /gaming [<game name> [server address]] + """ + if not args: + return self.xmpp.plugin['xep_0196'].stop() + + name = args[0] + if len(args) > 1: + address = args[1] + else: + address = None + return self.xmpp.plugin['xep_0196'].publish_gaming(name=name, + server_address=address, + callback=dumb_callback) + +@command_args_parser.quoted(2, 1, [None]) +def command_invite(self, args): + """/invite <to> <room> [reason]""" + + if args is None: + return self.command_help('invite') + + reason = args[2] + to = safeJID(args[0]) + room = safeJID(args[1]).bare + self.invite(to.full, room, reason=reason) + self.information('Invited %s to %s' % (to.bare, room), 'Info') + +@command_args_parser.quoted(1, 1, ['']) +def command_decline(self, args): + """/decline <room@server.tld> [reason]""" + if args is None: + return self.command_help('decline') + jid = safeJID(args[0]) + if jid.bare not in self.pending_invites: + return + reason = args[1] + del self.pending_invites[jid.bare] + self.xmpp.plugin['xep_0045'].decline_invite(jid.bare, + self.pending_invites[jid.bare], + reason) + +### Commands without a completion in this class ### + +@command_args_parser.ignored +def command_invitations(self): + """/invitations""" + build = "" + for invite in self.pending_invites: + build += "%s by %s" % (invite, + safeJID(self.pending_invites[invite]).bare) + if self.pending_invites: + build = "You are invited to the following rooms:\n" + build + else: + build = "You do not have any pending invitations." + self.information(build, 'Info') + +@command_args_parser.quoted(0, 1, [None]) +def command_quit(self, args): + """ + /quit [message] + """ + if not self.xmpp.is_connected(): + self.exit() + return + + msg = args[0] + if config.get('enable_user_mood'): + self.xmpp.plugin['xep_0107'].stop() + if config.get('enable_user_activity'): + self.xmpp.plugin['xep_0108'].stop() + if config.get('enable_user_gaming'): + self.xmpp.plugin['xep_0196'].stop() + self.save_config() + self.plugin_manager.disable_plugins() + self.disconnect(msg) + self.xmpp.add_event_handler("disconnected", self.exit, disposable=True) + +@command_args_parser.quoted(0, 1, ['']) +def command_destroy_room(self, args): + """ + /destroy_room [JID] + """ + room = safeJID(args[0]).bare + if room: + muc.destroy_room(self.xmpp, room) + elif isinstance(self.current_tab(), tabs.MucTab) and not args[0]: + muc.destroy_room(self.xmpp, self.current_tab().general_jid) + else: + self.information('Invalid JID: "%s"' % args[0], 'Error') + +@command_args_parser.quoted(1, 1, ['']) +def command_bind(self, args): + """ + Bind a key. + """ + if args is None: + return self.command_help('bind') + + if not config.silent_set(args[0], args[1], section='bindings'): + self.information('Unable to write in the config file', 'Error') + + if args[1]: + self.information('%s is now bound to %s' % (args[0], args[1]), 'Info') + else: + self.information('%s is now unbound' % args[0], 'Info') + +@command_args_parser.raw +def command_rawxml(self, args): + """ + /rawxml <xml stanza> + """ + + if not args: + return + + stanza = args + try: + stanza = StanzaBase(self.xmpp, xml=ET.fromstring(stanza)) + if stanza.xml.tag == 'iq' and stanza.xml.attrib.get('type') in ('get', 'set'): + iq_id = stanza.xml.attrib.get('id') + if not iq_id: + iq_id = self.xmpp.new_id() + stanza['id'] = iq_id + + def iqfunc(iq): + "handler for an iq reply" + self.information('%s' % iq, 'Iq') + self.xmpp.remove_handler('Iq %s' % iq_id) + + self.xmpp.register_handler( + Callback('Iq %s' % iq_id, + StanzaPath('iq@id=%s' % iq_id), + iqfunc + ) + ) + log.debug('handler') + log.debug('%s %s', stanza.xml.tag, stanza.xml.attrib) + + stanza.send() + except: + self.information('Could not send custom stanza', 'Error') + log.debug('/rawxml: Could not send custom stanza (%s)', + repr(stanza), + exc_info=True) + + +@command_args_parser.quoted(1, 256) +def command_load(self, args): + """ + /load <plugin> [<otherplugin> …] + # TODO: being able to load more than 256 plugins at once, hihi. + """ + for plugin in args: + self.plugin_manager.load(plugin) + +@command_args_parser.quoted(1, 256) +def command_unload(self, args): + """ + /unload <plugin> [<otherplugin> …] + """ + for plugin in args: + self.plugin_manager.unload(plugin) + +@command_args_parser.ignored +def command_plugins(self): + """ + /plugins + """ + self.information("Plugins currently in use: %s" % + repr(list(self.plugin_manager.plugins.keys())), + 'Info') + +@command_args_parser.quoted(1, 1) +def command_message(self, args): + """ + /message <jid> [message] + """ + if args is None: + return self.command_help('message') + jid = safeJID(args[0]) + if not jid.user and not jid.domain and not jid.resource: + return self.information('Invalid JID.', 'Error') + tab = self.get_conversation_by_jid(jid.full, False, fallback_barejid=False) + muc = self.get_tab_by_name(jid.bare, typ=tabs.MucTab) + if not tab and not muc: + tab = self.open_conversation_window(jid.full, focus=True) + elif muc: + tab = self.get_tab_by_name(jid.full, typ=tabs.PrivateTab) + if tab: + self.focus_tab_named(tab.name) + else: + tab = self.open_private_window(jid.bare, jid.resource) + else: + self.focus_tab_named(tab.name) + if len(args) == 2: + tab.command_say(args[1]) + +@command_args_parser.ignored +def command_xml_tab(self): + """/xml_tab""" + xml_tab = self.focus_tab_named('XMLTab', tabs.XMLTab) + if not xml_tab: + tab = tabs.XMLTab() + self.add_tab(tab, True) + self.xml_tab = tab + +@command_args_parser.quoted(1) +def command_adhoc(self, args): + if not args: + return self.command_help('ad-hoc') + jid = safeJID(args[0]) + list_tab = tabs.AdhocCommandsListTab(jid) + self.add_tab(list_tab, True) + cb = list_tab.on_list_received + self.xmpp.plugin['xep_0050'].get_commands(jid=jid, local=False, + callback=cb) + +@command_args_parser.ignored +def command_self(self): + """ + /self + """ + status = self.get_status() + show, message = status.show, status.message + nick = self.own_nick + jid = self.xmpp.boundjid.full + info = ('Your JID is %s\nYour current status is "%s" (%s)' + '\nYour default nickname is %s\nYou are running poezio %s' % ( + jid, + message if message else '', + show if show else 'available', + nick, + config_opts.version)) + self.information(info, 'Info') + + +@command_args_parser.ignored +def command_reload(self): + """ + /reload + """ + self.reload_config() + +def dumb_callback(*args, **kwargs): + "mock callback" + diff --git a/poezio/core/completions.py b/poezio/core/completions.py new file mode 100644 index 00000000..9fd44f1b --- /dev/null +++ b/poezio/core/completions.py @@ -0,0 +1,387 @@ +""" +Completions for the global commands +""" +import logging + +log = logging.getLogger(__name__) + +import os +from functools import reduce + +import common +import pep +import tabs +from common import safeJID +from config import config +from roster import roster + +from . structs import possible_show + + +def completion_help(self, the_input): + """Completion for /help.""" + commands = sorted(self.commands.keys()) + sorted(self.current_tab().commands.keys()) + return the_input.new_completion(commands, 1, quotify=False) + + +def completion_status(self, the_input): + """ + Completion of /status + """ + if the_input.get_argument_position() == 1: + return the_input.new_completion([status for status in possible_show], 1, ' ', quotify=False) + + +def completion_presence(self, the_input): + """ + Completion of /presence + """ + arg = the_input.get_argument_position() + if arg == 1: + return the_input.auto_completion([jid for jid in roster.jids()], '', quotify=True) + elif arg == 2: + return the_input.auto_completion([status for status in possible_show], '', quotify=True) + + +def completion_theme(self, the_input): + """ Completion for /theme""" + themes_dir = config.get('themes_dir') + themes_dir = themes_dir or\ + os.path.join(os.environ.get('XDG_DATA_HOME') or\ + os.path.join(os.environ.get('HOME'), '.local', 'share'), + 'poezio', 'themes') + themes_dir = os.path.expanduser(themes_dir) + try: + names = os.listdir(themes_dir) + except OSError as e: + log.error('Completion for /theme failed', exc_info=True) + return + theme_files = [name[:-3] for name in names if name.endswith('.py') and name != '__init__.py'] + if not 'default' in theme_files: + theme_files.append('default') + return the_input.new_completion(theme_files, 1, '', quotify=False) + + +def completion_win(self, the_input): + """Completion for /win""" + l = [] + for tab in self.tabs: + l.extend(tab.matching_names()) + l = [i[1] for i in l] + return the_input.new_completion(l, 1, '', quotify=False) + + +def completion_join(self, the_input): + """ + Completion for /join + + Try to complete the MUC JID: + if only a resource is provided, complete with the default nick + if only a server is provided, complete with the rooms from the + disco#items of that server + if only a nodepart is provided, complete with the servers of the + current joined rooms + """ + n = the_input.get_argument_position(quoted=True) + args = common.shell_split(the_input.text) + if n != 1: + # we are not on the 1st argument of the command line + return False + if len(args) == 1: + args.append('') + jid = safeJID(args[1]) + if args[1].endswith('@') and not jid.user and not jid.server: + jid.user = args[1][:-1] + + relevant_rooms = [] + relevant_rooms.extend(sorted(self.pending_invites.keys())) + bookmarks = {str(elem.jid): False for elem in self.bookmarks} + for tab in self.get_tabs(tabs.MucTab): + name = tab.name + if name in bookmarks and not tab.joined: + bookmarks[name] = True + relevant_rooms.extend(sorted(room[0] for room in bookmarks.items() if room[1])) + + if the_input.last_completion: + return the_input.new_completion([], 1, quotify=True) + + if jid.user: + # we are writing the server: complete the server + serv_list = [] + for tab in self.get_tabs(tabs.MucTab): + if tab.joined: + serv_list.append('%s@%s'% (jid.user, safeJID(tab.name).host)) + serv_list.extend(relevant_rooms) + return the_input.new_completion(serv_list, 1, quotify=True) + elif args[1].startswith('/'): + # we completing only a resource + return the_input.new_completion(['/%s' % self.own_nick], 1, quotify=True) + else: + return the_input.new_completion(relevant_rooms, 1, quotify=True) + + +def completion_version(self, the_input): + """Completion for /version""" + comp = reduce(lambda x, y: x + [i.jid for i in y], (roster[jid].resources for jid in roster.jids() if len(roster[jid])), []) + return the_input.new_completion(sorted(comp), 1, quotify=False) + + +def completion_list(self, the_input): + """Completion for /list""" + muc_serv_list = [] + for tab in self.get_tabs(tabs.MucTab): # TODO, also from an history + if tab.name not in muc_serv_list: + muc_serv_list.append(safeJID(tab.name).server) + if muc_serv_list: + return the_input.new_completion(muc_serv_list, 1, quotify=False) + + +def completion_move_tab(self, the_input): + """Completion for /move_tab""" + n = the_input.get_argument_position(quoted=True) + if n == 1: + nodes = [tab.name for tab in self.tabs if tab] + nodes.remove('Roster') + return the_input.new_completion(nodes, 1, ' ', quotify=True) + + +def completion_runkey(self, the_input): + """ + Completion for /runkey + """ + list_ = [] + list_.extend(self.key_func.keys()) + list_.extend(self.current_tab().key_func.keys()) + return the_input.new_completion(list_, 1, quotify=False) + + +def completion_bookmark(self, the_input): + """Completion for /bookmark""" + args = common.shell_split(the_input.text) + n = the_input.get_argument_position(quoted=True) + + if n == 2: + return the_input.new_completion(['true', 'false'], 2, quotify=True) + if n >= 3: + return + + if len(args) == 1: + args.append('') + jid = safeJID(args[1]) + + if jid.server and (jid.resource or jid.full.endswith('/')): + tab = self.get_tab_by_name(jid.bare, tabs.MucTab) + nicks = [tab.own_nick] if tab else [] + default = os.environ.get('USER') if os.environ.get('USER') else 'poezio' + nick = config.get('default_nick') + if not nick: + if not default in nicks: + nicks.append(default) + else: + if not nick in nicks: + nicks.append(nick) + jids_list = ['%s/%s' % (jid.bare, nick) for nick in nicks] + return the_input.new_completion(jids_list, 1, quotify=True) + muc_list = [tab.name for tab in self.get_tabs(tabs.MucTab)] + muc_list.sort() + muc_list.append('*') + return the_input.new_completion(muc_list, 1, quotify=True) + + +def completion_remove_bookmark(self, the_input): + """Completion for /remove_bookmark""" + return the_input.new_completion([bm.jid for bm in self.bookmarks], 1, quotify=False) + + +def completion_decline(self, the_input): + """Completion for /decline""" + n = the_input.get_argument_position(quoted=True) + if n == 1: + return the_input.auto_completion(sorted(self.pending_invites.keys()), 1, '', quotify=True) + + +def completion_bind(self, the_input): + n = the_input.get_argument_position() + if n == 1: + args = [key for key in self.key_func if not key.startswith('_')] + elif n == 2: + args = [key for key in self.key_func] + else: + return + + return the_input.new_completion(args, n, '', quotify=False) + + +def completion_message(self, the_input): + """Completion for /message""" + n = the_input.get_argument_position(quoted=True) + if n >= 2: + return + l = [] + for jid in roster.jids(): + if len(roster[jid]): + l.append(jid) + for resource in roster[jid].resources: + l.append(resource.jid) + return the_input.new_completion(l, 1, '', quotify=True) + + +def completion_invite(self, the_input): + """Completion for /invite""" + n = the_input.get_argument_position(quoted=True) + if n == 1: + comp = reduce(lambda x, y: x + [i.jid for i in y], (roster[jid].resources for jid in roster.jids() if len(roster[jid])), []) + comp = sorted(comp) + bares = sorted(roster[contact].bare_jid for contact in roster.jids() if len(roster[contact])) + off = sorted(jid for jid in roster.jids() if jid not in bares) + comp = comp + bares + off + return the_input.new_completion(comp, n, quotify=True) + elif n == 2: + rooms = [] + for tab in self.get_tabs(tabs.MucTab): + if tab.joined: + rooms.append(tab.name) + rooms.sort() + return the_input.new_completion(rooms, n, '', quotify=True) + + +def completion_activity(self, the_input): + """Completion for /activity""" + n = the_input.get_argument_position(quoted=True) + args = common.shell_split(the_input.text) + if n == 1: + return the_input.new_completion(sorted(pep.ACTIVITIES.keys()), n, quotify=True) + elif n == 2: + if args[1] in pep.ACTIVITIES: + l = list(pep.ACTIVITIES[args[1]]) + l.remove('category') + l.sort() + return the_input.new_completion(l, n, quotify=True) + + +def completion_mood(self, the_input): + """Completion for /mood""" + n = the_input.get_argument_position(quoted=True) + if n == 1: + return the_input.new_completion(sorted(pep.MOODS.keys()), 1, quotify=True) + + +def completion_last_activity(self, the_input): + """ + Completion for /last_activity <jid> + """ + n = the_input.get_argument_position(quoted=False) + if n >= 2: + return + comp = reduce(lambda x, y: x + [i.jid for i in y], (roster[jid].resources for jid in roster.jids() if len(roster[jid])), []) + return the_input.new_completion(sorted(comp), 1, '', quotify=False) + + +def completion_server_cycle(self, the_input): + """Completion for /server_cycle""" + serv_list = set() + for tab in self.get_tabs(tabs.MucTab): + serv = safeJID(tab.name).server + serv_list.add(serv) + return the_input.new_completion(sorted(serv_list), 1, ' ') + + +def completion_set(self, the_input): + """Completion for /set""" + args = common.shell_split(the_input.text) + n = the_input.get_argument_position(quoted=True) + if n >= len(args): + args.append('') + if n == 1: + if '|' in args[1]: + plugin_name, section = args[1].split('|')[:2] + if not plugin_name in self.plugin_manager.plugins: + return the_input.new_completion([], n, quotify=True) + plugin = self.plugin_manager.plugins[plugin_name] + end_list = ['%s|%s' % (plugin_name, section) for section in plugin.config.sections()] + else: + end_list = set(config.options('Poezio')) + end_list.update(config.default.get('Poezio', {})) + end_list = list(end_list) + end_list.sort() + elif n == 2: + if '|' in args[1]: + plugin_name, section = args[1].split('|')[:2] + if not plugin_name in self.plugin_manager.plugins: + return the_input.new_completion([''], n, quotify=True) + plugin = self.plugin_manager.plugins[plugin_name] + end_list = set(plugin.config.options(section or plugin_name)) + if plugin.config.default: + end_list.update(plugin.config.default.get(section or plugin_name, {})) + end_list = list(end_list) + end_list.sort() + elif not config.has_option('Poezio', args[1]): + if config.has_section(args[1]): + end_list = config.options(args[1]) + end_list.append('') + else: + end_list = [] + else: + end_list = [str(config.get(args[1], '')), ''] + elif n == 3: + if '|' in args[1]: + plugin_name, section = args[1].split('|')[:2] + if not plugin_name in self.plugin_manager.plugins: + return the_input.new_completion([''], n, quotify=True) + plugin = self.plugin_manager.plugins[plugin_name] + end_list = [str(plugin.config.get(args[2], '', section or plugin_name)), ''] + else: + if not config.has_section(args[1]): + end_list = [''] + else: + end_list = [str(config.get(args[2], '', args[1])), ''] + else: + return + return the_input.new_completion(end_list, n, quotify=True) + + +def completion_set_default(self, the_input): + """ Completion for /set_default + """ + args = common.shell_split(the_input.text) + n = the_input.get_argument_position(quoted=True) + if n >= len(args): + args.append('') + if n == 1 or (n == 2 and config.has_section(args[1])): + return self.completion_set(the_input) + return [] + + +def completion_toggle(self, the_input): + "Completion for /toggle" + return the_input.new_completion(config.options('Poezio'), 1, quotify=False) + + +def completion_bookmark_local(self, the_input): + """Completion for /bookmark_local""" + n = the_input.get_argument_position(quoted=True) + args = common.shell_split(the_input.text) + + if n >= 2: + return + if len(args) == 1: + args.append('') + jid = safeJID(args[1]) + + if jid.server and (jid.resource or jid.full.endswith('/')): + tab = self.get_tab_by_name(jid.bare, tabs.MucTab) + nicks = [tab.own_nick] if tab else [] + default = os.environ.get('USER') if os.environ.get('USER') else 'poezio' + nick = config.get('default_nick') + if not nick: + if not default in nicks: + nicks.append(default) + else: + if not nick in nicks: + nicks.append(nick) + jids_list = ['%s/%s' % (jid.bare, nick) for nick in nicks] + return the_input.new_completion(jids_list, 1, quotify=True) + muc_list = [tab.name for tab in self.get_tabs(tabs.MucTab)] + muc_list.append('*') + return the_input.new_completion(muc_list, 1, quotify=True) + diff --git a/poezio/core/core.py b/poezio/core/core.py new file mode 100644 index 00000000..f32099f1 --- /dev/null +++ b/poezio/core/core.py @@ -0,0 +1,2102 @@ +""" +Module defining the Core class, which is the central orchestrator +of poezio and contains the main loop, the list of tabs, sets the state +of everything; it also contains global commands, completions and event +handlers but those are defined in submodules in order to avoir cluttering +this file. +""" +import logging + +log = logging.getLogger(__name__) + +import asyncio +import shutil +import curses +import os +import pipes +import sys +import time + +from slixmpp.xmlstream.handler import Callback + +import connection +import decorators +import events +import singleton +import tabs +import theming +import timed_events +import windows + +from bookmarks import BookmarkList +from common import safeJID +from config import config, firstrun +from contact import Contact, Resource +from daemon import Executor +from fifo import Fifo +from logger import logger +from plugin_manager import PluginManager +from roster import roster +from size_manager import SizeManager +from text_buffer import TextBuffer +from theming import get_theme +import keyboard + +from . import completions +from . import commands +from . import handlers +from . structs import possible_show, DEPRECATED_ERRORS, \ + ERROR_AND_STATUS_CODES, Command, Status + + +class Core(object): + """ + “Main” class of poezion + """ + + def __init__(self): + # All uncaught exception are given to this callback, instead + # of being displayed on the screen and exiting the program. + sys.excepthook = self.on_exception + self.connection_time = time.time() + self.stdscr = None + status = config.get('status') + status = possible_show.get(status, None) + self.status = Status(show=status, + message=config.get('status_message')) + self.running = True + self.xmpp = singleton.Singleton(connection.Connection) + self.xmpp.core = self + self.keyboard = keyboard.Keyboard() + roster.set_node(self.xmpp.client_roster) + decorators.refresh_wrapper.core = self + self.bookmarks = BookmarkList() + self.debug = False + self.remote_fifo = None + # a unique buffer used to store global informations + # that are displayed in almost all tabs, in an + # information window. + self.information_buffer = TextBuffer() + self.information_win_size = config.get('info_win_height', section='var') + self.information_win = windows.TextWin(300) + self.information_buffer.add_window(self.information_win) + self.left_tab_win = None + + self.tab_win = windows.GlobalInfoBar() + # Whether the XML tab is opened + self.xml_tab = None + self.xml_buffer = TextBuffer() + + self.tabs = [] + self._current_tab_nb = 0 + self.previous_tab_nb = 0 + + own_nick = config.get('default_nick') + own_nick = own_nick or self.xmpp.boundjid.user + own_nick = own_nick or os.environ.get('USER') + own_nick = own_nick or 'poezio' + self.own_nick = own_nick + + self.plugins_autoloaded = False + self.plugin_manager = PluginManager(self) + self.events = events.EventHandler() + + self.size = SizeManager(self, windows.Win) + + # Set to True whenever we consider that we have been disconnected + # from the server because of a legitimate reason (bad credentials, + # or explicit disconnect from the user for example), in that case we + # should not try to auto-reconnect, even if auto_reconnect is true + # in the user config. + self.legitimate_disconnect = False + + # global commands, available from all tabs + # a command is tuple of the form: + # (the function executing the command. Takes a string as argument, + # a string representing the help message, + # a completion function, taking a Input as argument. Can be None) + # The completion function should return True if a completion was + # made ; False otherwise + self.commands = {} + self.register_initial_commands() + + # We are invisible + if not config.get('send_initial_presence'): + del self.commands['status'] + del self.commands['show'] + + # A list of integers. For example if the user presses Alt+j, 2, 1, + # we will insert 2, then 1 in that list, and we will finally build + # the number 21 and use it with command_win, before clearing the + # list. + self.room_number_jump = [] + self.key_func = KeyDict() + # Key bindings associated with handlers + # and pseudo-keys used to map actions below. + key_func = { + "KEY_PPAGE": self.scroll_page_up, + "KEY_NPAGE": self.scroll_page_down, + "^B": self.scroll_line_up, + "^F": self.scroll_line_down, + "^X": self.scroll_half_down, + "^S": self.scroll_half_up, + "KEY_F(5)": self.rotate_rooms_left, + "^P": self.rotate_rooms_left, + "M-[-D": self.rotate_rooms_left, + 'kLFT3': self.rotate_rooms_left, + "KEY_F(6)": self.rotate_rooms_right, + "^N": self.rotate_rooms_right, + "M-[-C": self.rotate_rooms_right, + 'kRIT3': self.rotate_rooms_right, + "KEY_F(4)": self.toggle_left_pane, + "KEY_F(7)": self.shrink_information_win, + "KEY_F(8)": self.grow_information_win, + "KEY_RESIZE": self.call_for_resize, + 'M-e': self.go_to_important_room, + 'M-r': self.go_to_roster, + 'M-z': self.go_to_previous_tab, + '^L': self.full_screen_redraw, + 'M-j': self.go_to_room_number, + 'M-D': self.scroll_info_up, + 'M-C': self.scroll_info_down, + 'M-k': self.escape_next_key, + ######## actions mappings ########## + '_bookmark': self.command_bookmark, + '_bookmark_local': self.command_bookmark_local, + '_close_tab': self.close_tab, + '_disconnect': self.disconnect, + '_quit': self.command_quit, + '_redraw_screen': self.full_screen_redraw, + '_reload_theme': self.command_theme, + '_remove_bookmark': self.command_remove_bookmark, + '_room_left': self.rotate_rooms_left, + '_room_right': self.rotate_rooms_right, + '_show_roster': self.go_to_roster, + '_scroll_down': self.scroll_page_down, + '_scroll_up': self.scroll_page_up, + '_scroll_info_up': self.scroll_info_up, + '_scroll_info_down': self.scroll_info_down, + '_server_cycle': self.command_server_cycle, + '_show_bookmarks': self.command_bookmarks, + '_show_important_room': self.go_to_important_room, + '_show_invitations': self.command_invitations, + '_show_plugins': self.command_plugins, + '_show_xmltab': self.command_xml_tab, + '_toggle_pane': self.toggle_left_pane, + ###### status actions ###### + '_available': lambda: self.command_status('available'), + '_away': lambda: self.command_status('away'), + '_chat': lambda: self.command_status('chat'), + '_dnd': lambda: self.command_status('dnd'), + '_xa': lambda: self.command_status('xa'), + ##### Custom actions ######## + '_exc_': self.try_execute, + } + self.key_func.update(key_func) + + # Add handlers + self.xmpp.add_event_handler('connecting', self.on_connecting) + self.xmpp.add_event_handler('connected', self.on_connected) + self.xmpp.add_event_handler('connection_failed', self.on_failed_connection) + self.xmpp.add_event_handler('disconnected', self.on_disconnected) + self.xmpp.add_event_handler('stream_error', self.on_stream_error) + self.xmpp.add_event_handler('failed_all_auth', self.on_failed_all_auth) + self.xmpp.add_event_handler('no_auth', self.on_no_auth) + self.xmpp.add_event_handler("session_start", self.on_session_start) + self.xmpp.add_event_handler("session_start", + self.on_session_start_features) + self.xmpp.add_event_handler("groupchat_presence", + self.on_groupchat_presence) + self.xmpp.add_event_handler("groupchat_message", + self.on_groupchat_message) + self.xmpp.add_event_handler("groupchat_invite", + self.on_groupchat_invitation) + self.xmpp.add_event_handler("groupchat_direct_invite", + self.on_groupchat_direct_invitation) + self.xmpp.add_event_handler("groupchat_decline", + self.on_groupchat_decline) + self.xmpp.add_event_handler("groupchat_config_status", + self.on_status_codes) + self.xmpp.add_event_handler("groupchat_subject", + self.on_groupchat_subject) + self.xmpp.add_event_handler("message", self.on_message) + self.xmpp.add_event_handler("message_error", self.on_error_message) + self.xmpp.add_event_handler("receipt_received", self.on_receipt) + self.xmpp.add_event_handler("got_online", self.on_got_online) + self.xmpp.add_event_handler("got_offline", self.on_got_offline) + self.xmpp.add_event_handler("roster_update", self.on_roster_update) + self.xmpp.add_event_handler("changed_status", self.on_presence) + self.xmpp.add_event_handler("presence_error", self.on_presence_error) + self.xmpp.add_event_handler("roster_subscription_request", + self.on_subscription_request) + self.xmpp.add_event_handler("roster_subscription_authorized", + self.on_subscription_authorized) + self.xmpp.add_event_handler("roster_subscription_remove", + self.on_subscription_remove) + self.xmpp.add_event_handler("roster_subscription_removed", + self.on_subscription_removed) + self.xmpp.add_event_handler("message_xform", self.on_data_form) + self.xmpp.add_event_handler("chatstate_active", + self.on_chatstate_active) + self.xmpp.add_event_handler("chatstate_composing", + self.on_chatstate_composing) + self.xmpp.add_event_handler("chatstate_paused", + self.on_chatstate_paused) + self.xmpp.add_event_handler("chatstate_gone", + self.on_chatstate_gone) + self.xmpp.add_event_handler("chatstate_inactive", + self.on_chatstate_inactive) + self.xmpp.add_event_handler("attention", self.on_attention) + self.xmpp.add_event_handler("ssl_cert", self.validate_ssl) + self.xmpp.add_event_handler("ssl_invalid_chain", self.ssl_invalid_chain) + self.xmpp.add_event_handler('carbon_received', self.on_carbon_received) + self.xmpp.add_event_handler('carbon_sent', self.on_carbon_sent) + + self.all_stanzas = Callback('custom matcher', + connection.MatchAll(None), + self.incoming_stanza) + self.xmpp.register_handler(self.all_stanzas) + if config.get('enable_user_tune'): + self.xmpp.add_event_handler("user_tune_publish", + self.on_tune_event) + if config.get('enable_user_nick'): + self.xmpp.add_event_handler("user_nick_publish", + self.on_nick_received) + if config.get('enable_user_mood'): + self.xmpp.add_event_handler("user_mood_publish", + self.on_mood_event) + if config.get('enable_user_activity'): + self.xmpp.add_event_handler("user_activity_publish", + self.on_activity_event) + if config.get('enable_user_gaming'): + self.xmpp.add_event_handler("user_gaming_publish", + self.on_gaming_event) + + self.initial_joins = [] + + self.connected_events = {} + + self.pending_invites = {} + + # a dict of the form {'config_option': [list, of, callbacks]} + # Whenever a configuration option is changed (using /set or by + # reloading a new config using a signal), all the associated + # callbacks are triggered. + # Use Core.add_configuration_handler("option", callback) to add a + # handler + # Note that the callback will be called when it’s changed in the + # global section, OR in a special section. + # As a special case, handlers can be associated with the empty + # string option (""), they will be called for every option change + # The callback takes two argument: the config option, and the new + # value + self.configuration_change_handlers = {"": []} + self.add_configuration_handler("create_gaps", + self.on_gaps_config_change) + self.add_configuration_handler("request_message_receipts", + self.on_request_receipts_config_change) + self.add_configuration_handler("ack_message_receipts", + self.on_ack_receipts_config_change) + self.add_configuration_handler("plugins_dir", + self.on_plugins_dir_config_change) + self.add_configuration_handler("plugins_conf_dir", + self.on_plugins_conf_dir_config_change) + self.add_configuration_handler("connection_timeout_delay", + self.xmpp.set_keepalive_values) + self.add_configuration_handler("connection_check_interval", + self.xmpp.set_keepalive_values) + self.add_configuration_handler("themes_dir", + theming.update_themes_dir) + self.add_configuration_handler("theme", + self.on_theme_config_change) + self.add_configuration_handler("use_bookmarks_method", + self.on_bookmarks_method_config_change) + self.add_configuration_handler("password", + self.on_password_change) + self.add_configuration_handler("enable_vertical_tab_list", + self.on_vertical_tab_list_config_change) + self.add_configuration_handler("vertical_tab_list_size", + self.on_vertical_tab_list_config_change) + self.add_configuration_handler("deterministic_nick_colors", + self.on_nick_determinism_changed) + self.add_configuration_handler("enable_carbons", + self.on_carbons_switch) + self.add_configuration_handler("hide_user_list", + self.on_hide_user_list_change) + + self.add_configuration_handler("", self.on_any_config_change) + + def on_any_config_change(self, option, value): + """ + Update the roster, in case a roster option changed. + """ + roster.modified() + + def add_configuration_handler(self, option, callback): + """ + Add a callback, associated with the given option. It will be called + each time the configuration option is changed using /set or by + reloading the configuration with a signal + """ + if option not in self.configuration_change_handlers: + self.configuration_change_handlers[option] = [] + self.configuration_change_handlers[option].append(callback) + + def trigger_configuration_change(self, option, value): + """ + Triggers all the handlers associated with the given configuration + option + """ + # First call the callbacks associated with any configuration change + for callback in self.configuration_change_handlers[""]: + callback(option, value) + # and then the callbacks associated with this specific option, if + # any + if option not in self.configuration_change_handlers: + return + for callback in self.configuration_change_handlers[option]: + callback(option, value) + + def on_hide_user_list_change(self, option, value): + """ + Called when the hide_user_list option changes + """ + self.call_for_resize() + + def on_bookmarks_method_config_change(self, option, value): + """ + Called when the use_bookmarks_method option changes + """ + if value not in ('pep', 'privatexml'): + return + self.bookmarks.preferred = value + self.bookmarks.save(self.xmpp, core=self) + + def on_gaps_config_change(self, option, value): + """ + Called when the option create_gaps is changed. + Remove all gaptabs if switching from gaps to nogaps. + """ + if value.lower() == "false": + self.tabs = list(tab for tab in self.tabs if tab) + + def on_request_receipts_config_change(self, option, value): + """ + Called when the request_message_receipts option changes + """ + self.xmpp.plugin['xep_0184'].auto_request = config.get(option, + default=True) + + def on_ack_receipts_config_change(self, option, value): + """ + Called when the ack_message_receipts option changes + """ + self.xmpp.plugin['xep_0184'].auto_ack = config.get(option, default=True) + + def on_plugins_dir_config_change(self, option, value): + """ + Called when the plugins_dir option is changed + """ + path = os.path.expanduser(value) + self.plugin_manager.on_plugins_dir_change(path) + + def on_vertical_tab_list_config_change(self, option, value): + """ + Called when the enable_vertical_tab_list option is changed + """ + self.call_for_resize() + + def on_plugins_conf_dir_config_change(self, option, value): + """ + Called when the plugins_conf_dir option is changed + """ + path = os.path.expanduser(value) + self.plugin_manager.on_plugins_conf_dir_change(path) + + def on_theme_config_change(self, option, value): + """ + Called when the theme option is changed + """ + error_msg = theming.reload_theme() + if error_msg: + self.information(error_msg, 'Warning') + self.refresh_window() + + def on_password_change(self, option, value): + """ + Set the new password in the slixmpp.ClientXMPP object + """ + self.xmpp.password = value + + + def on_nick_determinism_changed(self, option, value): + """If we change the value to true, we call /recolor on all the MucTabs, to + make the current nick colors reflect their deterministic value. + """ + if value.lower() == "true": + for tab in self.get_tabs(tabs.MucTab): + tab.command_recolor('') + + def on_carbons_switch(self, option, value): + """Whenever the user enables or disables carbons using /set, we should + inform the server immediately, this way we do not require a restart + for the change to take effect + """ + if value: + self.xmpp.plugin['xep_0280'].enable() + else: + self.xmpp.plugin['xep_0280'].disable() + + def reload_config(self): + # reload all log files + log.debug("Reloading the log files…") + logger.reload_all() + log.debug("Log files reloaded.") + # reload the theme + log.debug("Reloading the theme…") + theming.reload_theme() + log.debug("Theme reloaded.") + # reload the config from the disk + log.debug("Reloading the config…") + # Copy the old config in a dict + old_config = config.to_dict() + config.read_file() + # Compare old and current config, to trigger the callbacks of all + # modified options + for section in config.sections(): + old_section = old_config.get(section, {}) + for option in config.options(section): + old_value = old_section.get(option) + new_value = config.get(option, default="", section=section) + if new_value != old_value: + self.trigger_configuration_change(option, new_value) + log.debug("Config reloaded.") + # in case some roster options have changed + roster.modified() + + def sigusr_handler(self, num, stack): + """ + Handle SIGUSR1 (10) + When caught, reload all the possible files. + """ + log.debug("SIGUSR1 caught, reloading the files…") + self.reload_config() + + def exit_from_signal(self, *args, **kwargs): + """ + Quit when receiving SIGHUP or SIGTERM or SIGPIPE + + do not save the config because it is not a normal exit + (and only roster UI things are not yet saved) + """ + sig = args[0] + signals = { + 1: 'SIGHUP', + 13: 'SIGPIPE', + 15: 'SIGTERM', + } + + log.error("%s received. Exiting…", signals[sig]) + if config.get('enable_user_mood'): + self.xmpp.plugin['xep_0107'].stop() + if config.get('enable_user_activity'): + self.xmpp.plugin['xep_0108'].stop() + if config.get('enable_user_gaming'): + self.xmpp.plugin['xep_0196'].stop() + self.plugin_manager.disable_plugins() + self.disconnect('%s received' % signals.get(sig)) + self.xmpp.add_event_handler("disconnected", self.exit, disposable=True) + + def autoload_plugins(self): + """ + Load the plugins on startup. + """ + plugins = config.get('plugins_autoload') + if ':' in plugins: + for plugin in plugins.split(':'): + self.plugin_manager.load(plugin) + else: + for plugin in plugins.split(): + self.plugin_manager.load(plugin) + self.plugins_autoloaded = True + + def start(self): + """ + Init curses, create the first tab, etc + """ + self.stdscr = curses.initscr() + self.init_curses(self.stdscr) + self.call_for_resize() + default_tab = tabs.RosterInfoTab() + default_tab.on_gain_focus() + self.tabs.append(default_tab) + self.information('Welcome to poezio!', 'Info') + if firstrun: + self.information( + 'It seems that it is the first time you start poezio.\n' + 'The online help is here http://doc.poez.io/\n' + 'No room is joined by default, but you can join poezio’s' + ' chatroom (with /join poezio@muc.poez.io), where you can' + ' ask for help or tell us how great it is.', + 'Help') + self.refresh_window() + self.xmpp.plugin['xep_0012'].begin_idle(jid=self.xmpp.boundjid) + + def exit(self, event=None): + log.debug("exit(%s)" % (event,)) + asyncio.get_event_loop().stop() + + def on_exception(self, typ, value, trace): + """ + When an exception is raised, just reset curses and call + the original exception handler (will nicely print the traceback) + """ + try: + self.reset_curses() + except: + pass + sys.__excepthook__(typ, value, trace) + + def sigwinch_handler(self): + """A work-around for ncurses resize stuff, which sucks. Normally, ncurses + catches SIGWINCH itself. In its signal handler, it updates the + windows structures (for example the size, etc) and it + ungetch(KEY_RESIZE). That way, the next time we call getch() we know + that a resize occured and we can act on it. BUT poezio doesn’t call + getch() until it knows it will return something. The problem is we + can’t know that, because stdin is not affected by this KEY_RESIZE + value (it is only inserted in a ncurses internal fifo that we can’t + access). + + The (ugly) solution is to handle SIGWINCH ourself, trigger the + change of the internal windows sizes stored in ncurses module, using + sizes that we get using shutil, ungetch the KEY_RESIZE value and + then call getch to handle the resize on poezio’s side properly. + """ + size = shutil.get_terminal_size() + curses.resizeterm(size.lines, size.columns) + curses.ungetch(curses.KEY_RESIZE) + self.on_input_readable() + + def on_input_readable(self): + """ + main loop waiting for the user to press a key + """ + def replace_line_breaks(key): + "replace ^J with \n" + if key == '^J': + return '\n' + return key + def separate_chars_from_bindings(char_list): + """ + returns a list of lists. For example if you give + ['a', 'b', 'KEY_BACKSPACE', 'n', 'u'], this function returns + [['a', 'b'], ['KEY_BACKSPACE'], ['n', 'u']] + + This way, in case of lag (for example), we handle the typed text + by “batch” as much as possible (instead of one char at a time, + which implies a refresh after each char, which is very slow), + but we still handle the special chars (backspaces, arrows, + ctrl+x ou alt+x, etc) one by one, which avoids the issue of + printing them OR ignoring them in that case. This should + resolve the “my ^W are ignored when I lag ;(”. + """ + res = [] + current = [] + for char in char_list: + assert char + # Transform that stupid char into what we actually meant + if char == '\x1f': + char = '^/' + if len(char) == 1: + current.append(char) + else: + # special case for the ^I key, it’s considered as \t + # only when pasting some text, otherwise that’s the ^I + # (or M-i) key, which stands for completion by default. + if char == '^I' and len(char_list) != 1: + current.append('\t') + continue + if current: + res.append(current) + current = [] + res.append([char]) + if current: + res.append(current) + return res + + log.debug("Input is readable.") + big_char_list = [replace_key_with_bound(key)\ + for key in self.read_keyboard()] + log.debug("Got from keyboard: %s", (big_char_list,)) + + # whether to refresh after ALL keys have been handled + for char_list in separate_chars_from_bindings(big_char_list): + # Special case for M-x where x is a number + if len(char_list) == 1: + char = char_list[0] + if char.startswith('M-') and len(char) == 3: + try: + nb = int(char[2]) + except ValueError: + pass + else: + if self.current_tab().nb == nb and config.get('go_to_previous_tab_on_alt_number'): + self.go_to_previous_tab() + else: + self.command_win('%d' % nb) + # search for keyboard shortcut + func = self.key_func.get(char, None) + if func: + func() + else: + self.do_command(replace_line_breaks(char), False) + else: + self.do_command(''.join(char_list), True) + if self.status.show not in ('xa', 'away'): + self.xmpp.plugin['xep_0319'].idle() + self.doupdate() + + def save_config(self): + """ + Save config in the file just before exit + """ + ok = roster.save_to_config_file() + ok = ok and config.silent_set('info_win_height', + self.information_win_size, + 'var') + if not ok: + self.information('Unable to save runtime preferences' + ' in the config file', + 'Error') + + def on_roster_enter_key(self, roster_row): + """ + when enter is pressed on the roster window + """ + if isinstance(roster_row, Contact): + if not self.get_conversation_by_jid(roster_row.bare_jid, False): + self.open_conversation_window(roster_row.bare_jid) + else: + self.focus_tab_named(roster_row.bare_jid) + if isinstance(roster_row, Resource): + if not self.get_conversation_by_jid(roster_row.jid, + False, + fallback_barejid=False): + self.open_conversation_window(roster_row.jid) + else: + self.focus_tab_named(roster_row.jid) + self.refresh_window() + + def get_conversation_messages(self): + """ + Returns a list of all the messages in the current chat. + If the current tab is not a ChatTab, returns None. + + Messages are namedtuples of the form + ('txt nick_color time str_time nickname user') + """ + if not isinstance(self.current_tab(), tabs.ChatTab): + return None + return self.current_tab().get_conversation_messages() + + def insert_input_text(self, text): + """ + Insert the given text into the current input + """ + self.do_command(text, True) + + +##################### Anything related to command execution ################### + + def execute(self, line): + """ + Execute the /command or just send the line on the current room + """ + if line == "": + return + if line.startswith('/'): + command = line.strip()[:].split()[0][1:] + arg = line[2+len(command):] # jump the '/' and the ' ' + # example. on "/link 0 open", command = "link" and arg = "0 open" + if command in self.commands: + func = self.commands[command][0] + func(arg) + return + else: + self.information("Unknown command (%s)" % (command), + 'Error') + + def exec_command(self, command): + """ + Execute an external command on the local or a remote machine, + depending on the conf. For example, to open a link in a browser, do + exec_command(["firefox", "http://poezio.eu"]), and this will call + the command on the correct computer. + + The command argument is a list of strings, not quoted or escaped in + any way. The escaping is done here if needed. + + The remote execution is done + by writing the command on a fifo. That fifo has to be on the + machine where poezio is running, and accessible (through sshfs for + example) from the local machine (where poezio is not running). A + very simple daemon (daemon.py) reads on that fifo, and executes any + command that is read in it. Since we can only write strings to that + fifo, each argument has to be pipes.quote()d. That way the + shlex.split on the reading-side of the daemon will be safe. + + You cannot use a real command line with pipes, redirections etc, but + this function supports a simple case redirection to file: if the + before-last argument of the command is ">" or ">>", then the last + argument is considered to be a filename where the command stdout + will be written. For example you can do exec_command(["echo", + "coucou les amis coucou coucou", ">", "output.txt"]) and this will + work. If you try to do anything else, your |, [, <<, etc will be + interpreted as normal command arguments, not shell special tokens. + """ + if config.get('exec_remote'): + # We just write the command in the fifo + fifo_path = config.get('remote_fifo_path') + if not self.remote_fifo: + try: + self.remote_fifo = Fifo(os.path.join(fifo_path, + 'poezio.fifo'), + 'w') + except (OSError, IOError) as exc: + log.error('Could not open the fifo for writing (%s)', + os.path.join(fifo_path, './', 'poezio.fifo'), + exc_info=True) + self.information('Could not open the fifo ' + 'file for writing: %s' % exc, + 'Error') + return + + args = (pipes.quote(arg.replace('\n', ' ')) for arg in command) + command_str = ' '.join(args) + '\n' + try: + self.remote_fifo.write(command_str) + except (IOError) as exc: + log.error('Could not write in the fifo (%s): %s', + os.path.join(fifo_path, './', 'poezio.fifo'), + repr(command), + exc_info=True) + self.information('Could not execute %s: %s' % (command, exc), + 'Error') + self.remote_fifo = None + else: + executor = Executor(command) + try: + executor.start() + except ValueError as exc: + log.error('Could not execute command (%s)', + repr(command), + exc_info=True) + self.information('%s' % exc, 'Error') + + + def do_command(self, key, raw): + """ + Execute the action associated with a key + + Or if keyboard.continuation_keys_callback is set, call it instead. See + the comment of this variable. + """ + if not key: + return + if keyboard.continuation_keys_callback is not None: + # Reset the callback to None BEFORE calling it, because this + # callback MAY set a new callback itself, and we don’t want to + # erase it in that case + cb = keyboard.continuation_keys_callback + keyboard.continuation_keys_callback = None + cb(key) + else: + self.current_tab().on_input(key, raw) + + + def try_execute(self, line): + """ + Try to execute a command in the current tab + """ + line = '/' + line + try: + self.current_tab().execute_command(line) + except: + log.error('Execute failed (%s)', line, exc_info=True) + + +########################## TImed Events ####################################### + + def remove_timed_event(self, event): + """Remove an existing timed event""" + event.handler.cancel() + + def add_timed_event(self, event): + """Add a new timed event""" + event.handler = asyncio.get_event_loop().call_later(event.delay, + event.callback, + *event.args) + +####################### XMPP-related actions ################################## + + def get_status(self): + """ + Get the last status that was previously set + """ + return self.status + + def set_status(self, pres, msg): + """ + Set our current status so we can remember + it and use it back when needed (for example to display it + or to use it when joining a new muc) + """ + self.status = Status(show=pres, message=msg) + if config.get('save_status'): + ok = config.silent_set('status', pres if pres else '') + msg = msg.replace('\n', '|') if msg else '' + ok = ok and config.silent_set('status_message', msg) + if not ok: + self.information('Unable to save the status in ' + 'the config file', 'Error') + + def get_bookmark_nickname(self, room_name): + """ + Returns the nickname associated with a bookmark + or the default nickname + """ + bm = self.bookmarks[room_name] + if bm: + return bm.nick + return self.own_nick + + def disconnect(self, msg='', reconnect=False): + """ + Disconnect from remote server and correctly set the states of all + parts of the client (for example, set the MucTabs as not joined, etc) + """ + self.legitimate_disconnect = True + msg = msg or '' + for tab in self.get_tabs(tabs.MucTab): + tab.command_part(msg) + self.xmpp.disconnect() + if reconnect: + # Add a one-time event to reconnect as soon as we are + # effectively disconnected + self.xmpp.add_event_handler('disconnected', lambda event: self.xmpp.connect(), disposable=True) + + def send_message(self, msg): + """ + Function to use in plugins to send a message in the current + conversation. + Returns False if the current tab is not a conversation tab + """ + if not isinstance(self.current_tab(), tabs.ChatTab): + return False + self.current_tab().command_say(msg) + return True + + def invite(self, jid, room, reason=None): + """ + Checks if the sender supports XEP-0249, then send an invitation, + or a mediated one if it does not. + TODO: allow passwords + """ + def callback(iq): + if not iq: + return + if 'jabber:x:conference' in iq['disco_info'].get_features(): + self.xmpp.plugin['xep_0249'].send_invitation( + jid, + room, + reason=reason) + else: # fallback + self.xmpp.plugin['xep_0045'].invite(room, jid, + reason=reason or '') + + self.xmpp.plugin['xep_0030'].get_info(jid=jid, timeout=5, + callback=callback) + + def get_error_message(self, stanza, deprecated=False): + """ + Takes a stanza of the form <message type='error'><error/></message> + and return a well formed string containing the error informations + """ + sender = stanza.attrib['from'] + msg = stanza['error']['type'] + condition = stanza['error']['condition'] + code = stanza['error']['code'] + body = stanza['error']['text'] + if not body: + if deprecated: + if code in DEPRECATED_ERRORS: + body = DEPRECATED_ERRORS[code] + else: + body = condition or 'Unknown error' + else: + if code in ERROR_AND_STATUS_CODES: + body = ERROR_AND_STATUS_CODES[code] + else: + body = condition or 'Unknown error' + if code: + message = '%(from)s: %(code)s - %(msg)s: %(body)s' % { + 'from': sender, 'msg': msg, 'body': body, 'code': code} + else: + message = '%(from)s: %(msg)s: %(body)s' % { + 'from': sender, 'msg': msg, 'body': body} + return message + + +####################### Tab logic-related things ############################## + + ### Tab getters ### + + def get_tabs(self, cls=tabs.Tab): + "Get all the tabs of a type" + return filter(lambda tab: isinstance(tab, cls), self.tabs) + + def current_tab(self): + """ + returns the current room, the one we are viewing + """ + self.current_tab_nb = self.current_tab_nb + return self.tabs[self.current_tab_nb] + + def get_conversation_by_jid(self, jid, create=True, fallback_barejid=True): + """ + From a JID, get the tab containing the conversation with it. + If none already exist, and create is "True", we create it + and return it. Otherwise, we return None. + + If fallback_barejid is True, then this method will seek other + tabs with the same barejid, instead of searching only by fulljid. + """ + jid = safeJID(jid) + # We first check if we have a static conversation opened + # with this precise resource + conversation = self.get_tab_by_name(jid.full, + tabs.StaticConversationTab) + if jid.bare == jid.full and not conversation: + conversation = self.get_tab_by_name(jid.full, + tabs.DynamicConversationTab) + + if not conversation and fallback_barejid: + # If not, we search for a conversation with the bare jid + conversation = self.get_tab_by_name(jid.bare, + tabs.DynamicConversationTab) + if not conversation: + if create: + # We create a dynamic conversation with the bare Jid if + # nothing was found (and we lock it to the resource + # later) + conversation = self.open_conversation_window(jid.bare, + False) + else: + conversation = None + return conversation + + def get_tab_by_name(self, name, typ=None): + """ + Get the tab with the given name. + If typ is provided, return a tab of this type only + """ + for tab in self.tabs: + if tab.name == name: + if (typ and isinstance(tab, typ)) or\ + not typ: + return tab + return None + + def get_tab_by_number(self, number): + if 0 <= number < len(self.tabs): + return self.tabs[number] + return None + + def add_tab(self, new_tab, focus=False): + """ + Appends the new_tab in the tab list and + focus it if focus==True + """ + self.tabs.append(new_tab) + if focus: + self.command_win("%s" % new_tab.nb) + + def insert_tab_nogaps(self, old_pos, new_pos): + """ + Move tabs without creating gaps + old_pos: old position of the tab + new_pos: desired position of the tab + """ + tab = self.tabs[old_pos] + if new_pos < old_pos: + self.tabs.pop(old_pos) + self.tabs.insert(new_pos, tab) + elif new_pos > old_pos: + self.tabs.insert(new_pos, tab) + self.tabs.remove(tab) + else: + return False + return True + + def insert_tab_gaps(self, old_pos, new_pos): + """ + Move tabs and create gaps in the eventual remaining space + old_pos: old position of the tab + new_pos: desired position of the tab + """ + tab = self.tabs[old_pos] + target = None if new_pos >= len(self.tabs) else self.tabs[new_pos] + if not target: + if new_pos < len(self.tabs): + old_tab = self.tabs[old_pos] + self.tabs[new_pos], self.tabs[old_pos] = old_tab, tabs.GapTab() + else: + self.tabs.append(self.tabs[old_pos]) + self.tabs[old_pos] = tabs.GapTab() + else: + if new_pos > old_pos: + self.tabs.insert(new_pos, tab) + self.tabs[old_pos] = tabs.GapTab() + elif new_pos < old_pos: + self.tabs[old_pos] = tabs.GapTab() + self.tabs.insert(new_pos, tab) + else: + return False + i = self.tabs.index(tab) + done = False + # Remove the first Gap on the right in the list + # in order to prevent global shifts when there is empty space + while not done: + i += 1 + if i >= len(self.tabs): + done = True + elif not self.tabs[i]: + self.tabs.pop(i) + done = True + # Remove the trailing gaps + i = len(self.tabs) - 1 + while isinstance(self.tabs[i], tabs.GapTab): + self.tabs.pop() + i -= 1 + return True + + def insert_tab(self, old_pos, new_pos=99999): + """ + Insert a tab at a position, changing the number of the following tabs + returns False if it could not move the tab, True otherwise + """ + if old_pos <= 0 or old_pos >= len(self.tabs): + return False + elif new_pos <= 0: + return False + elif new_pos == old_pos: + return False + elif not self.tabs[old_pos]: + return False + if config.get('create_gaps'): + return self.insert_tab_gaps(old_pos, new_pos) + return self.insert_tab_nogaps(old_pos, new_pos) + + ### Move actions (e.g. go to next room) ### + + def rotate_rooms_right(self, args=None): + """ + rotate the rooms list to the right + """ + self.current_tab().on_lose_focus() + self.current_tab_nb += 1 + while not self.tabs[self.current_tab_nb]: + self.current_tab_nb += 1 + self.current_tab().on_gain_focus() + self.refresh_window() + + def rotate_rooms_left(self, args=None): + """ + rotate the rooms list to the right + """ + self.current_tab().on_lose_focus() + self.current_tab_nb -= 1 + while not self.tabs[self.current_tab_nb]: + self.current_tab_nb -= 1 + self.current_tab().on_gain_focus() + self.refresh_window() + + def go_to_room_number(self): + """ + Read 2 more chars and go to the tab + with the given number + """ + def read_next_digit(digit): + try: + nb = int(digit) + except ValueError: + # If it is not a number, we do nothing. If it was the first + # one, we do not wait for a second one by re-setting the + # callback + self.room_number_jump.clear() + else: + self.room_number_jump.append(digit) + if len(self.room_number_jump) == 2: + arg = "".join(self.room_number_jump) + self.room_number_jump.clear() + self.command_win(arg) + else: + # We need to read more digits + keyboard.continuation_keys_callback = read_next_digit + keyboard.continuation_keys_callback = read_next_digit + + def go_to_roster(self): + "Select the roster as the current tab" + self.command_win('0') + + def go_to_previous_tab(self): + "Go to the previous tab" + self.command_win('%s' % (self.previous_tab_nb,)) + + def go_to_important_room(self): + """ + Go to the next room with activity, in the order defined in the + dict tabs.STATE_PRIORITY + """ + # shortcut + priority = tabs.STATE_PRIORITY + tab_refs = {} + # put all the active tabs in a dict of lists by state + for tab in self.tabs: + if not tab: + continue + if tab.state not in tab_refs: + tab_refs[tab.state] = [tab] + else: + tab_refs[tab.state].append(tab) + # sort the state by priority and remove those with negative priority + states = sorted(tab_refs.keys(), + key=(lambda x: priority.get(x, 0)), + reverse=True) + states = [state for state in states if priority.get(state, -1) >= 0] + + for state in states: + for tab in tab_refs[state]: + if (tab.nb < self.current_tab_nb and + tab_refs[state][-1].nb > self.current_tab_nb): + continue + self.command_win('%s' % tab.nb) + return + return + + def focus_tab_named(self, tab_name, type_=None): + """Returns True if it found a tab to focus on""" + for tab in self.tabs: + if tab.name == tab_name: + if (type_ and (isinstance(tab, type_))) or not type_: + self.command_win('%s' % (tab.nb,)) + return True + return False + + @property + def current_tab_nb(self): + """Wrapper for the current tab number""" + return self._current_tab_nb + + @current_tab_nb.setter + def current_tab_nb(self, value): + """ + Prevents the tab number from going over the total number of opened + tabs, or under 0 + """ + old = self._current_tab_nb + if value >= len(self.tabs): + self._current_tab_nb = 0 + elif value < 0: + self._current_tab_nb = len(self.tabs) - 1 + else: + self._current_tab_nb = value + if old != self._current_tab_nb and self.tabs[self._current_tab_nb]: + self.events.trigger('tab_change', old, self._current_tab_nb) + + ### Opening actions ### + + def open_conversation_window(self, jid, focus=True): + """ + Open a new conversation tab and focus it if needed. If a resource is + provided, we open a StaticConversationTab, else a + DynamicConversationTab + """ + if safeJID(jid).resource: + new_tab = tabs.StaticConversationTab(jid) + else: + new_tab = tabs.DynamicConversationTab(jid) + if not focus: + new_tab.state = "private" + self.add_tab(new_tab, focus) + self.refresh_window() + return new_tab + + def open_private_window(self, room_name, user_nick, focus=True): + """ + Open a Private conversation in a MUC and focus if needed. + """ + complete_jid = room_name+'/'+user_nick + # if the room exists, focus it and return + for tab in self.get_tabs(tabs.PrivateTab): + if tab.name == complete_jid: + self.command_win('%s' % tab.nb) + return tab + # create the new tab + tab = self.get_tab_by_name(room_name, tabs.MucTab) + if not tab: + return None + new_tab = tabs.PrivateTab(complete_jid, tab.own_nick) + if hasattr(tab, 'directed_presence'): + new_tab.directed_presence = tab.directed_presence + if not focus: + new_tab.state = "private" + # insert it in the tabs + self.add_tab(new_tab, focus) + self.refresh_window() + tab.privates.append(new_tab) + return new_tab + + def open_new_room(self, room, nick, *, password=None, focus=True): + """ + Open a new tab.MucTab containing a muc Room, using the specified nick + """ + new_tab = tabs.MucTab(room, nick, password=password) + self.add_tab(new_tab, focus) + self.refresh_window() + return new_tab + + def open_new_form(self, form, on_cancel, on_send, **kwargs): + """ + Open a new tab containing the form + The callback are called with the completed form as parameter in + addition with kwargs + """ + form_tab = tabs.DataFormsTab(form, on_cancel, on_send, kwargs) + self.add_tab(form_tab, True) + + ### Modifying actions ### + + def rename_private_tabs(self, room_name, old_nick, new_nick): + """ + Call this method when someone changes his/her nick in a MUC, + this updates the name of all the opened private conversations + with him/her + """ + tab = self.get_tab_by_name('%s/%s' % (room_name, old_nick), + tabs.PrivateTab) + if tab: + tab.rename_user(old_nick, new_nick) + + def on_user_left_private_conversation(self, room_name, nick, status_message): + """ + The user left the MUC: add a message in the associated + private conversation + """ + tab = self.get_tab_by_name('%s/%s' % (room_name, nick), tabs.PrivateTab) + if tab: + tab.user_left(status_message, nick) + + def on_user_rejoined_private_conversation(self, room_name, nick): + """ + The user joined a MUC: add a message in the associated + private conversation + """ + tab = self.get_tab_by_name('%s/%s' % (room_name, nick), tabs.PrivateTab) + if tab: + tab.user_rejoined(nick) + + def disable_private_tabs(self, room_name, reason=None): + """ + Disable private tabs when leaving a room + """ + if reason is None: + reason = '\x195}You left the chatroom\x193}' + for tab in self.get_tabs(tabs.PrivateTab): + if tab.name.startswith(room_name): + tab.deactivate(reason=reason) + + def enable_private_tabs(self, room_name, reason=None): + """ + Enable private tabs when joining a room + """ + if reason is None: + reason = '\x195}You joined the chatroom\x193}' + for tab in self.get_tabs(tabs.PrivateTab): + if tab.name.startswith(room_name): + tab.activate(reason=reason) + + def on_user_changed_status_in_private(self, jid, msg): + tab = self.get_tab_by_name(jid, tabs.ChatTab) + if tab: # display the message in private + tab.add_message(msg, typ=2) + + def close_tab(self, tab=None): + """ + Close the given tab. If None, close the current one + """ + was_current = tab is None + tab = tab or self.current_tab() + if isinstance(tab, tabs.RosterInfoTab): + return # The tab 0 should NEVER be closed + del tab.key_func # Remove self references + del tab.commands # and make the object collectable + tab.on_close() + nb = tab.nb + if was_current: + if self.previous_tab_nb != nb: + self.current_tab_nb = self.previous_tab_nb + self.previous_tab_nb = 0 + if config.get('create_gaps'): + if nb >= len(self.tabs) - 1: + self.tabs.remove(tab) + nb -= 1 + while not self.tabs[nb]: # remove the trailing gaps + self.tabs.pop() + nb -= 1 + else: + self.tabs[nb] = tabs.GapTab() + else: + self.tabs.remove(tab) + if tab and tab.name in logger.fds: + logger.fds[tab.name].close() + log.debug("Log file for %s closed.", tab.name) + del logger.fds[tab.name] + if self.current_tab_nb >= len(self.tabs): + self.current_tab_nb = len(self.tabs) - 1 + while not self.tabs[self.current_tab_nb]: + self.current_tab_nb -= 1 + if was_current: + self.current_tab().on_gain_focus() + self.refresh_window() + import gc + gc.collect() + log.debug('___ Referrers of closing tab:\n%s\n______', + gc.get_referrers(tab)) + del tab + + def add_information_message_to_conversation_tab(self, jid, msg): + """ + Search for a ConversationTab with the given jid (full or bare), + if yes, add the given message to it + """ + tab = self.get_tab_by_name(jid, tabs.ConversationTab) + if tab: + tab.add_message(msg, typ=2) + if self.current_tab() is tab: + self.refresh_window() + + +####################### Curses and ui-related stuff ########################### + + def doupdate(self): + "Do a curses update" + if not self.running: + return + curses.doupdate() + + def information(self, msg, typ=''): + """ + Displays an informational message in the "Info" buffer + """ + filter_messages = config.get('filter_info_messages').split(':') + for words in filter_messages: + if words and words in msg: + log.debug('Did not show the message:\n\t%s> %s', typ, msg) + return False + colors = get_theme().INFO_COLORS + color = colors.get(typ.lower(), colors.get('default', None)) + nb_lines = self.information_buffer.add_message(msg, + nickname=typ, + nick_color=color) + popup_on = config.get('information_buffer_popup_on').split() + if isinstance(self.current_tab(), tabs.RosterInfoTab): + self.refresh_window() + elif typ != '' and typ.lower() in popup_on: + popup_time = config.get('popup_time') + (nb_lines - 1) * 2 + self.pop_information_win_up(nb_lines, popup_time) + else: + if self.information_win_size != 0: + self.information_win.refresh() + self.current_tab().input.refresh() + return True + + def init_curses(self, stdscr): + """ + ncurses initialization + """ + curses.curs_set(1) + curses.noecho() + curses.nonl() + curses.raw() + stdscr.idlok(1) + stdscr.keypad(1) + curses.start_color() + curses.use_default_colors() + theming.reload_theme() + curses.ungetch(" ") # H4X: without this, the screen is + stdscr.getkey() # erased on the first "getkey()" + + def reset_curses(self): + """ + Reset terminal capabilities to what they were before ncurses + init + """ + curses.echo() + curses.nocbreak() + curses.curs_set(1) + curses.endwin() + + @property + def informations(self): + return self.information_buffer + + def refresh_window(self): + """ + Refresh everything + """ + nocursor = curses.curs_set(0) + self.current_tab().state = 'current' + self.current_tab().refresh() + self.doupdate() + curses.curs_set(nocursor) + + def refresh_tab_win(self): + """ + Refresh the window containing the tab list + """ + self.current_tab().refresh_tab_win() + self.refresh_input() + self.doupdate() + + def refresh_input(self): + """ + Refresh the input if it exists + """ + if self.current_tab().input: + self.current_tab().input.refresh() + self.doupdate() + + def scroll_page_down(self, args=None): + """ + Scroll a page down, if possible. + Returns True on success, None on failure. + """ + if self.current_tab().on_scroll_down(): + self.refresh_window() + return True + + def scroll_page_up(self, args=None): + """ + Scroll a page up, if possible. + Returns True on success, None on failure. + """ + if self.current_tab().on_scroll_up(): + self.refresh_window() + return True + + def scroll_line_up(self, args=None): + """ + Scroll a line up, if possible. + Returns True on success, None on failure. + """ + if self.current_tab().on_line_up(): + self.refresh_window() + return True + + def scroll_line_down(self, args=None): + """ + Scroll a line down, if possible. + Returns True on success, None on failure. + """ + if self.current_tab().on_line_down(): + self.refresh_window() + return True + + def scroll_half_up(self, args=None): + """ + Scroll half a screen down, if possible. + Returns True on success, None on failure. + """ + if self.current_tab().on_half_scroll_up(): + self.refresh_window() + return True + + def scroll_half_down(self, args=None): + """ + Scroll half a screen down, if possible. + Returns True on success, None on failure. + """ + if self.current_tab().on_half_scroll_down(): + self.refresh_window() + return True + + def grow_information_win(self, nb=1): + """ + Expand the information win a number of lines + """ + if self.information_win_size >= self.current_tab().height -5 or \ + self.information_win_size+nb >= self.current_tab().height-4 or\ + self.size.core_degrade_y: + return 0 + self.information_win_size += nb + self.resize_global_information_win() + for tab in self.tabs: + tab.on_info_win_size_changed() + self.refresh_window() + return nb + + def shrink_information_win(self, nb=1): + """ + Reduce the size of the information win + """ + if self.information_win_size == 0 or self.size.core_degrade_y: + return + self.information_win_size -= nb + if self.information_win_size < 0: + self.information_win_size = 0 + self.resize_global_information_win() + for tab in self.tabs: + tab.on_info_win_size_changed() + self.refresh_window() + + def scroll_info_up(self): + """ + Scroll the information buffer up + """ + self.information_win.scroll_up(self.information_win.height) + if not isinstance(self.current_tab(), tabs.RosterInfoTab): + self.information_win.refresh() + else: + info = self.current_tab().information_win + info.scroll_up(info.height) + self.refresh_window() + + def scroll_info_down(self): + """ + Scroll the information buffer down + """ + self.information_win.scroll_down(self.information_win.height) + if not isinstance(self.current_tab(), tabs.RosterInfoTab): + self.information_win.refresh() + else: + info = self.current_tab().information_win + info.scroll_down(info.height) + self.refresh_window() + + def pop_information_win_up(self, size, time): + """ + Temporarly increase the size of the information win of size lines + during time seconds. + After that delay, the size will decrease from size lines. + """ + if time <= 0 or size <= 0: + return + result = self.grow_information_win(size) + timed_event = timed_events.DelayedEvent(time, + self.shrink_information_win, + result) + self.add_timed_event(timed_event) + self.refresh_window() + + def toggle_left_pane(self): + """ + Enable/disable the left panel. + """ + enabled = config.get('enable_vertical_tab_list') + if not config.silent_set('enable_vertical_tab_list', str(not enabled)): + self.information('Unable to write in the config file', 'Error') + self.call_for_resize() + + def resize_global_information_win(self): + """ + Resize the global_information_win only once at each resize. + """ + if self.information_win_size > tabs.Tab.height - 6: + self.information_win_size = tabs.Tab.height - 6 + if tabs.Tab.height < 6: + self.information_win_size = 0 + height = (tabs.Tab.height - 1 - self.information_win_size + - tabs.Tab.tab_win_height()) + self.information_win.resize(self.information_win_size, + tabs.Tab.width, + height, + 0) + + def resize_global_info_bar(self): + """ + Resize the GlobalInfoBar only once at each resize + """ + height, width = self.stdscr.getmaxyx() + if config.get('enable_vertical_tab_list'): + + if self.size.core_degrade_x: + return + try: + height, _ = self.stdscr.getmaxyx() + truncated_win = self.stdscr.subwin(height, + config.get('vertical_tab_list_size'), + 0, 0) + except: + log.error('Curses error on infobar resize', exc_info=True) + return + self.left_tab_win = windows.VerticalGlobalInfoBar(truncated_win) + elif not self.size.core_degrade_y: + self.tab_win.resize(1, tabs.Tab.width, + tabs.Tab.height - 2, 0) + self.left_tab_win = None + + def add_message_to_text_buffer(self, buff, txt, + time=None, nickname=None, history=None): + """ + Add the message to the room if possible, else, add it to the Info window + (in the Info tab of the info window in the RosterTab) + """ + if not buff: + self.information('Trying to add a message in no room: %s' % txt, 'Error') + else: + buff.add_message(txt, time, nickname, history=history) + + def full_screen_redraw(self): + """ + Completely erase and redraw the screen + """ + self.stdscr.clear() + self.refresh_window() + + def call_for_resize(self): + """ + Called when we want to resize the screen + """ + # If we have the tabs list on the left, we just give a truncated + # window to each Tab class, so they draw themself in the portion of + # the screen that they can occupy, and we draw the tab list on the + # remaining space, on the left + height, width = self.stdscr.getmaxyx() + if (config.get('enable_vertical_tab_list') and + not self.size.core_degrade_x): + try: + scr = self.stdscr.subwin(0, + config.get('vertical_tab_list_size')) + except: + log.error('Curses error on resize', exc_info=True) + return + else: + scr = self.stdscr + tabs.Tab.resize(scr) + self.resize_global_info_bar() + self.resize_global_information_win() + for tab in self.tabs: + if config.get('lazy_resize'): + tab.need_resize = True + else: + tab.resize() + if self.tabs: + self.full_screen_redraw() + + def read_keyboard(self): + """ + Get the next keyboard key pressed and returns it. It blocks until + something can be read on stdin, this function must be called only if + there is something to read. No timeout ever occurs. + """ + return self.keyboard.get_user_input(self.stdscr) + + def escape_next_key(self): + """ + Tell the Keyboard object that the next key pressed by the user + should be escaped. See Keyboard.get_user_input + """ + self.keyboard.escape_next_key() + +####################### Commands and completions ############################## + + def register_command(self, name, func, **kwargs): + """ + Add a command + """ + desc = kwargs.get('desc', '') + shortdesc = kwargs.get('shortdesc', '') + completion = kwargs.get('completion') + usage = kwargs.get('usage', '') + if name in self.commands: + return + if not desc and shortdesc: + desc = shortdesc + self.commands[name] = Command(func, desc, completion, shortdesc, usage) + + def register_initial_commands(self): + """ + Register the commands when poezio starts + """ + self.register_command('help', self.command_help, + usage='[command]', + shortdesc='\\_o< KOIN KOIN KOIN', + completion=self.completion_help) + self.register_command('join', self.command_join, + usage="[room_name][@server][/nick] [password]", + desc="Join the specified room. You can specify a nickname " + "after a slash (/). If no nickname is specified, you will" + " use the default_nick in the configuration file. You can" + " omit the room name: you will then join the room you\'re" + " looking at (useful if you were kicked). You can also " + "provide a room_name without specifying a server, the " + "server of the room you're currently in will be used. You" + " can also provide a password to join the room.\nExamples" + ":\n/join room@server.tld\n/join room@server.tld/John\n" + "/join room2\n/join /me_again\n/join\n/join room@server" + ".tld/my_nick password\n/join / password", + shortdesc='Join a room', + completion=self.completion_join) + self.register_command('exit', self.command_quit, + desc='Just disconnect from the server and exit poezio.', + shortdesc='Exit poezio.') + self.register_command('quit', self.command_quit, + desc='Just disconnect from the server and exit poezio.', + shortdesc='Exit poezio.') + self.register_command('next', self.rotate_rooms_right, + shortdesc='Go to the next room.') + self.register_command('prev', self.rotate_rooms_left, + shortdesc='Go to the previous room.') + self.register_command('win', self.command_win, + usage='<number or name>', + shortdesc='Go to the specified room', + completion=self.completion_win) + self.commands['w'] = self.commands['win'] + self.register_command('move_tab', self.command_move_tab, + usage='<source> <destination>', + desc="Insert the <source> tab at the position of " + "<destination>. This will make the following tabs shift in" + " some cases (refer to the documentation). A tab can be " + "designated by its number or by the beginning of its " + "address. You can use \".\" as a shortcut for the current " + "tab.", + shortdesc='Move a tab.', + completion=self.completion_move_tab) + self.register_command('destroy_room', self.command_destroy_room, + usage='[room JID]', + desc='Try to destroy the room [room JID], or the current' + ' tab if it is a multi-user chat and [room JID] is ' + 'not given.', + shortdesc='Destroy a room.', + completion=None) + self.register_command('show', self.command_status, + usage='<availability> [status message]', + desc="Sets your availability and (optionally) your status " + "message. The <availability> argument is one of \"available" + ", chat, away, afk, dnd, busy, xa\" and the optional " + "[status message] argument will be your status message.", + shortdesc='Change your availability.', + completion=self.completion_status) + self.commands['status'] = self.commands['show'] + self.register_command('bookmark_local', self.command_bookmark_local, + usage="[roomname][/nick] [password]", + desc="Bookmark Local: Bookmark locally the specified room " + "(you will then auto-join it on each poezio start). This" + " commands uses almost the same syntaxe as /join. Type " + "/help join for syntax examples. Note that when typing " + "\"/bookmark\" on its own, the room will be bookmarked " + "with the nickname you\'re currently using in this room " + "(instead of default_nick)", + shortdesc='Bookmark a room locally.', + completion=self.completion_bookmark_local) + self.register_command('bookmark', self.command_bookmark, + usage="[roomname][/nick] [autojoin] [password]", + desc="Bookmark: Bookmark online the specified room (you " + "will then auto-join it on each poezio start if autojoin" + " is specified and is 'true'). This commands uses almost" + " the same syntax as /join. Type /help join for syntax " + "examples. Note that when typing \"/bookmark\" alone, the" + " room will be bookmarked with the nickname you\'re " + "currently using in this room (instead of default_nick).", + shortdesc="Bookmark a room online.", + completion=self.completion_bookmark) + self.register_command('set', self.command_set, + usage="[plugin|][section] <option> [value]", + desc="Set the value of an option in your configuration file." + " You can, for example, change your default nickname by " + "doing `/set default_nick toto` or your resource with `/set" + " resource blabla`. You can also set options in specific " + "sections with `/set bindings M-i ^i` or in specific plugin" + " with `/set mpd_client| host 127.0.0.1`. `toggle` can be " + "used as a special value to toggle a boolean option.", + shortdesc="Set the value of an option", + completion=self.completion_set) + self.register_command('set_default', self.command_set_default, + usage="[section] <option>", + desc="Set the default value of an option. For example, " + "`/set_default resource` will reset the resource " + "option. You can also reset options in specific " + "sections by doing `/set_default section option`.", + shortdesc="Set the default value of an option", + completion=self.completion_set_default) + self.register_command('toggle', self.command_toggle, + usage='<option>', + desc='Shortcut for /set <option> toggle', + shortdesc='Toggle an option', + completion=self.completion_toggle) + self.register_command('theme', self.command_theme, + usage='[theme name]', + desc="Reload the theme defined in the config file. If theme" + "_name is provided, set that theme before reloading it.", + shortdesc='Load a theme', + completion=self.completion_theme) + self.register_command('list', self.command_list, + usage='[server]', + desc="Get the list of public chatrooms" + " on the specified server.", + shortdesc='List the rooms.', + completion=self.completion_list) + self.register_command('message', self.command_message, + usage='<jid> [optional message]', + desc="Open a conversation with the specified JID (even if it" + " is not in our roster), and send a message to it, if the " + "message is specified.", + shortdesc='Send a message', + completion=self.completion_message) + self.register_command('version', self.command_version, + usage='<jid>', + desc="Get the software version of the given JID (usually its" + " XMPP client and Operating System).", + shortdesc='Get the software version of a JID.', + completion=self.completion_version) + self.register_command('server_cycle', self.command_server_cycle, + usage='[domain] [message]', + desc='Disconnect and reconnect in all the rooms in domain.', + shortdesc='Cycle a range of rooms', + completion=self.completion_server_cycle) + self.register_command('bind', self.command_bind, + usage='<key> <equ>', + desc="Bind a key to another key or to a “command”. For " + "example \"/bind ^H KEY_UP\" makes Control + h do the" + " same same as the Up key.", + completion=self.completion_bind, + shortdesc='Bind a key to another key.') + self.register_command('load', self.command_load, + usage='<plugin> [<otherplugin> …]', + shortdesc='Load the specified plugin(s)', + completion=self.plugin_manager.completion_load) + self.register_command('unload', self.command_unload, + usage='<plugin> [<otherplugin> …]', + shortdesc='Unload the specified plugin(s)', + completion=self.plugin_manager.completion_unload) + self.register_command('plugins', self.command_plugins, + shortdesc='Show the plugins in use.') + self.register_command('presence', self.command_presence, + usage='<JID> [type] [status]', + desc="Send a directed presence to <JID> and using" + " [type] and [status] if provided.", + shortdesc='Send a directed presence.', + completion=self.completion_presence) + self.register_command('rawxml', self.command_rawxml, + usage='<xml>', + shortdesc='Send a custom xml stanza.') + self.register_command('invite', self.command_invite, + usage='<jid> <room> [reason]', + desc='Invite jid in room with reason.', + shortdesc='Invite someone in a room.', + completion=self.completion_invite) + self.register_command('invitations', self.command_invitations, + shortdesc='Show the pending invitations.') + self.register_command('bookmarks', self.command_bookmarks, + shortdesc='Show the current bookmarks.') + self.register_command('remove_bookmark', self.command_remove_bookmark, + usage='[jid]', + desc="Remove the specified bookmark, or the " + "bookmark on the current tab, if any.", + shortdesc='Remove a bookmark', + completion=self.completion_remove_bookmark) + self.register_command('xml_tab', self.command_xml_tab, + shortdesc='Open an XML tab.') + self.register_command('runkey', self.command_runkey, + usage='<key>', + shortdesc='Execute the action defined for <key>.', + completion=self.completion_runkey) + self.register_command('self', self.command_self, + shortdesc='Remind you of who you are.') + self.register_command('last_activity', self.command_last_activity, + usage='<jid>', + desc='Informs you of the last activity of a JID.', + shortdesc='Get the activity of someone.', + completion=self.completion_last_activity) + self.register_command('ad-hoc', self.command_adhoc, + usage='<jid>', + shortdesc='List available ad-hoc commands on the given jid') + self.register_command('reload', self.command_reload, + shortdesc='Reload the config. You can achieve the same by ' + 'sending SIGUSR1 to poezio.') + + if config.get('enable_user_activity'): + self.register_command('activity', self.command_activity, + usage='[<general> [specific] [text]]', + desc='Send your current activity to your contacts ' + '(use the completion). Nothing means ' + '"stop broadcasting an activity".', + shortdesc='Send your activity.', + completion=self.completion_activity) + if config.get('enable_user_mood'): + self.register_command('mood', self.command_mood, + usage='[<mood> [text]]', + desc='Send your current mood to your contacts ' + '(use the completion). Nothing means ' + '"stop broadcasting a mood".', + shortdesc='Send your mood.', + completion=self.completion_mood) + if config.get('enable_user_gaming'): + self.register_command('gaming', self.command_gaming, + usage='[<game name> [server address]]', + desc='Send your current gaming activity to ' + 'your contacts. Nothing means "stop ' + 'broadcasting a gaming activity".', + shortdesc='Send your gaming activity.', + completion=None) + +####################### XMPP Event Handlers ################################## + on_session_start_features = handlers.on_session_start_features + on_carbon_received = handlers.on_carbon_received + on_carbon_sent = handlers.on_carbon_sent + on_groupchat_invitation = handlers.on_groupchat_invitation + on_groupchat_direct_invitation = handlers.on_groupchat_direct_invitation + on_groupchat_decline = handlers.on_groupchat_decline + on_message = handlers.on_message + on_error_message = handlers.on_error_message + on_normal_message = handlers.on_normal_message + on_nick_received = handlers.on_nick_received + on_gaming_event = handlers.on_gaming_event + on_mood_event = handlers.on_mood_event + on_activity_event = handlers.on_activity_event + on_tune_event = handlers.on_tune_event + on_groupchat_message = handlers.on_groupchat_message + on_muc_own_nickchange = handlers.on_muc_own_nickchange + on_groupchat_private_message = handlers.on_groupchat_private_message + on_chatstate_active = handlers.on_chatstate_active + on_chatstate_inactive = handlers.on_chatstate_inactive + on_chatstate_composing = handlers.on_chatstate_composing + on_chatstate_paused = handlers.on_chatstate_paused + on_chatstate_gone = handlers.on_chatstate_gone + on_chatstate = handlers.on_chatstate + on_chatstate_normal_conversation = handlers.on_chatstate_normal_conversation + on_chatstate_private_conversation = \ + handlers.on_chatstate_private_conversation + on_chatstate_groupchat_conversation = \ + handlers.on_chatstate_groupchat_conversation + on_roster_update = handlers.on_roster_update + on_subscription_request = handlers.on_subscription_request + on_subscription_authorized = handlers.on_subscription_authorized + on_subscription_remove = handlers.on_subscription_remove + on_subscription_removed = handlers.on_subscription_removed + on_presence = handlers.on_presence + on_presence_error = handlers.on_presence_error + on_got_offline = handlers.on_got_offline + on_got_online = handlers.on_got_online + on_groupchat_presence = handlers.on_groupchat_presence + on_failed_connection = handlers.on_failed_connection + on_disconnected = handlers.on_disconnected + on_stream_error = handlers.on_stream_error + on_failed_all_auth = handlers.on_failed_all_auth + on_no_auth = handlers.on_no_auth + on_connected = handlers.on_connected + on_connecting = handlers.on_connecting + on_session_start = handlers.on_session_start + on_status_codes = handlers.on_status_codes + on_groupchat_subject = handlers.on_groupchat_subject + on_data_form = handlers.on_data_form + on_receipt = handlers.on_receipt + on_attention = handlers.on_attention + room_error = handlers.room_error + check_bookmark_storage = handlers.check_bookmark_storage + outgoing_stanza = handlers.outgoing_stanza + incoming_stanza = handlers.incoming_stanza + validate_ssl = handlers.validate_ssl + ssl_invalid_chain = handlers.ssl_invalid_chain + on_next_adhoc_step = handlers.on_next_adhoc_step + on_adhoc_error = handlers.on_adhoc_error + cancel_adhoc_command = handlers.cancel_adhoc_command + validate_adhoc_step = handlers.validate_adhoc_step + terminate_adhoc_command = handlers.terminate_adhoc_command + command_help = commands.command_help + command_runkey = commands.command_runkey + command_status = commands.command_status + command_presence = commands.command_presence + command_theme = commands.command_theme + command_win = commands.command_win + command_move_tab = commands.command_move_tab + command_list = commands.command_list + command_version = commands.command_version + command_join = commands.command_join + command_bookmark_local = commands.command_bookmark_local + command_bookmark = commands.command_bookmark + command_bookmarks = commands.command_bookmarks + command_destroy_room = commands.command_destroy_room + command_remove_bookmark = commands.command_remove_bookmark + command_set = commands.command_set + command_set_default = commands.command_set_default + command_toggle = commands.command_toggle + command_server_cycle = commands.command_server_cycle + command_last_activity = commands.command_last_activity + command_mood = commands.command_mood + command_activity = commands.command_activity + command_gaming = commands.command_gaming + command_invite = commands.command_invite + command_decline = commands.command_decline + command_invitations = commands.command_invitations + command_quit = commands.command_quit + command_bind = commands.command_bind + command_rawxml = commands.command_rawxml + command_load = commands.command_load + command_unload = commands.command_unload + command_plugins = commands.command_plugins + command_message = commands.command_message + command_xml_tab = commands.command_xml_tab + command_adhoc = commands.command_adhoc + command_self = commands.command_self + command_reload = commands.command_reload + completion_help = completions.completion_help + completion_status = completions.completion_status + completion_presence = completions.completion_presence + completion_theme = completions.completion_theme + completion_win = completions.completion_win + completion_join = completions.completion_join + completion_version = completions.completion_version + completion_list = completions.completion_list + completion_move_tab = completions.completion_move_tab + completion_runkey = completions.completion_runkey + completion_bookmark = completions.completion_bookmark + completion_remove_bookmark = completions.completion_remove_bookmark + completion_decline = completions.completion_decline + completion_bind = completions.completion_bind + completion_message = completions.completion_message + completion_invite = completions.completion_invite + completion_activity = completions.completion_activity + completion_mood = completions.completion_mood + completion_last_activity = completions.completion_last_activity + completion_server_cycle = completions.completion_server_cycle + completion_set = completions.completion_set + completion_set_default = completions.completion_set_default + completion_toggle = completions.completion_toggle + completion_bookmark_local = completions.completion_bookmark_local + + + +class KeyDict(dict): + """ + A dict, with a wrapper for get() that will return a custom value + if the key starts with _exc_ + """ + def get(self, k, d=None): + if isinstance(k, str) and k.startswith('_exc_') and len(k) > 5: + return lambda: dict.get(self, '_exc_')(k[5:]) + return dict.get(self, k, d) + +def replace_key_with_bound(key): + """ + Replace an inputted key with the one defined as its replacement + in the config + """ + bind = config.get(key, default=key, section='bindings') + if not bind: + bind = key + return bind + + diff --git a/poezio/core/handlers.py b/poezio/core/handlers.py new file mode 100644 index 00000000..8cc08179 --- /dev/null +++ b/poezio/core/handlers.py @@ -0,0 +1,1354 @@ +""" +XMPP-related handlers for the Core class +""" + +import logging +log = logging.getLogger(__name__) + +import asyncio +import curses +import functools +import ssl +import sys +import time +from datetime import datetime +from hashlib import sha1, sha512 +from os import path + +from slixmpp import InvalidJID +from slixmpp.xmlstream.stanzabase import StanzaBase, ElementBase +from xml.etree import ElementTree as ET + +import common +import fixes +import pep +import tabs +import windows +import xhtml +import multiuserchat as muc +from common import safeJID +from config import config, CACHE_DIR +from contact import Resource +from logger import logger +from roster import roster +from text_buffer import CorrectionError, AckError +from theming import dump_tuple, get_theme + +from . commands import dumb_callback + +try: + from pygments import highlight + from pygments.lexers import get_lexer_by_name + from pygments.formatters import HtmlFormatter + LEXER = get_lexer_by_name('xml') + FORMATTER = HtmlFormatter(noclasses=True) + PYGMENTS = True +except ImportError: + PYGMENTS = False + +def _join_initial_rooms(self, bookmarks): + """Join all rooms given in the iterator `bookmarks`""" + for bm in bookmarks: + if not (bm.autojoin or config.get('open_all_bookmarks')): + continue + tab = self.get_tab_by_name(bm.jid, tabs.MucTab) + nick = bm.nick if bm.nick else self.own_nick + if not tab: + self.open_new_room(bm.jid, nick, focus=False, + password=bm.password) + self.initial_joins.append(bm.jid) + # do not join rooms that do not have autojoin + # but display them anyway + if bm.autojoin: + muc.join_groupchat(self, bm.jid, nick, + passwd=bm.password, + status=self.status.message, + show=self.status.show) + +def check_bookmark_storage(self, features): + private = 'jabber:iq:private' in features + pep_ = 'http://jabber.org/protocol/pubsub#publish' in features + self.bookmarks.available_storage['private'] = private + self.bookmarks.available_storage['pep'] = pep_ + def _join_remote_only(iq): + if iq['type'] == 'error': + type_ = iq['error']['type'] + condition = iq['error']['condition'] + if not (type_ == 'cancel' and condition == 'item-not-found'): + self.information('Unable to fetch the remote' + ' bookmarks; %s: %s' % (type_, condition), + 'Error') + return + remote_bookmarks = self.bookmarks.remote() + _join_initial_rooms(self, remote_bookmarks) + if not self.xmpp.anon and config.get('use_remote_bookmarks'): + self.bookmarks.get_remote(self.xmpp, self.information, _join_remote_only) + +def on_session_start_features(self, _): + """ + Enable carbons & blocking on session start if wanted and possible + """ + def callback(iq): + if not iq: + return + features = iq['disco_info']['features'] + rostertab = self.get_tab_by_name('Roster', tabs.RosterInfoTab) + rostertab.check_blocking(features) + rostertab.check_saslexternal(features) + if (config.get('enable_carbons') and + 'urn:xmpp:carbons:2' in features): + self.xmpp.plugin['xep_0280'].enable() + self.check_bookmark_storage(features) + + self.xmpp.plugin['xep_0030'].get_info(jid=self.xmpp.boundjid.domain, + callback=callback) + +def on_carbon_received(self, message): + """ + Carbon <received/> received + """ + def ignore_message(recv): + log.debug('%s has category conference, ignoring carbon', + recv['from'].server) + def receive_message(recv): + recv['to'] = self.xmpp.boundjid.full + if recv['receipt']: + return self.on_receipt(recv) + self.on_normal_message(recv) + + recv = message['carbon_received'] + if (recv['from'].bare not in roster or + roster[recv['from'].bare].subscription == 'none'): + fixes.has_identity(self.xmpp, recv['from'].server, + identity='conference', + on_true=functools.partial(ignore_message, recv), + on_false=functools.partial(receive_message, recv)) + return + else: + receive_message(recv) + +def on_carbon_sent(self, message): + """ + Carbon <sent/> received + """ + def ignore_message(sent): + log.debug('%s has category conference, ignoring carbon', + sent['to'].server) + def send_message(sent): + sent['from'] = self.xmpp.boundjid.full + self.on_normal_message(sent) + + sent = message['carbon_sent'] + if (sent['to'].bare not in roster or + roster[sent['to'].bare].subscription == 'none'): + fixes.has_identity(self.xmpp, sent['to'].server, + identity='conference', + on_true=functools.partial(ignore_message, sent), + on_false=functools.partial(send_message, sent)) + else: + send_message(sent) + +### Invites ### + +def on_groupchat_invitation(self, message): + """ + Mediated invitation received + """ + jid = message['from'] + if jid.bare in self.pending_invites: + return + # there are 2 'x' tags in the messages, making message['x'] useless + invite = StanzaBase(self.xmpp, xml=message.find('{http://jabber.org/protocol/muc#user}x/{http://jabber.org/protocol/muc#user}invite')) + inviter = invite['from'] + reason = invite['reason'] + password = invite['password'] + msg = "You are invited to the room %s by %s" % (jid.full, inviter.full) + if reason: + msg += "because: %s" % reason + if password: + msg += ". The password is \"%s\"." % password + self.information(msg, 'Info') + if 'invite' in config.get('beep_on').split(): + curses.beep() + logger.log_roster_change(inviter.full, 'invited you to %s' % jid.full) + self.pending_invites[jid.bare] = inviter.full + +def on_groupchat_decline(self, decline): + "Mediated invitation declined; skip for now" + pass + +def on_groupchat_direct_invitation(self, message): + """ + Direct invitation received + """ + room = safeJID(message['groupchat_invite']['jid']) + if room.bare in self.pending_invites: + return + + inviter = message['from'] + reason = message['groupchat_invite']['reason'] + password = message['groupchat_invite']['password'] + continue_ = message['groupchat_invite']['continue'] + msg = "You are invited to the room %s by %s" % (room, inviter.full) + + if password: + msg += ' (password: "%s")' % password + if continue_: + msg += '\nto continue the discussion' + if reason: + msg += "\nreason: %s" % reason + + self.information(msg, 'Info') + if 'invite' in config.get('beep_on').split(): + curses.beep() + + self.pending_invites[room.bare] = inviter.full + logger.log_roster_change(inviter.full, 'invited you to %s' % room.bare) + +### "classic" messages ### + +def on_message(self, message): + """ + When receiving private message from a muc OR a normal message + (from one of our contacts) + """ + if message.find('{http://jabber.org/protocol/muc#user}x/{http://jabber.org/protocol/muc#user}invite') != None: + return + if message['type'] == 'groupchat': + return + # Differentiate both type of messages, and call the appropriate handler. + jid_from = message['from'] + for tab in self.get_tabs(tabs.MucTab): + if tab.name == jid_from.bare: + if message['type'] == 'chat': + return self.on_groupchat_private_message(message) + return self.on_normal_message(message) + +def on_error_message(self, message): + """ + When receiving any message with type="error" + """ + jid_from = message['from'] + for tab in self.get_tabs(tabs.MucTab): + if tab.name == jid_from.bare: + if message['type'] == 'error': + return self.room_error(message, jid_from.bare) + else: + return self.on_groupchat_private_message(message) + tab = self.get_conversation_by_jid(message['from'], create=False) + error_msg = self.get_error_message(message, deprecated=True) + if not tab: + return self.information(error_msg, 'Error') + error = '\x19%s}%s\x19o' % (dump_tuple(get_theme().COLOR_CHAR_NACK), + error_msg) + if not tab.nack_message('\n' + error, message['id'], message['to']): + tab.add_message(error, typ=0) + self.refresh_window() + + +def on_normal_message(self, message): + """ + When receiving "normal" messages (not a private message from a + muc participant) + """ + if message['type'] == 'error': + return + elif message['type'] == 'headline' and message['body']: + return self.information('%s says: %s' % (message['from'], message['body']), 'Headline') + + use_xhtml = config.get('enable_xhtml_im') + tmp_dir = config.get('tmp_image_dir') or path.join(CACHE_DIR, 'images') + extract_images = config.get('extract_inline_images') + body = xhtml.get_body_from_message_stanza(message, use_xhtml=use_xhtml, + tmp_dir=tmp_dir, + extract_images=extract_images) + if not body: + return + + remote_nick = '' + # normal message, we are the recipient + if message['to'].bare == self.xmpp.boundjid.bare: + conv_jid = message['from'] + jid = conv_jid + color = get_theme().COLOR_REMOTE_USER + # check for a name + if conv_jid.bare in roster: + remote_nick = roster[conv_jid.bare].name + # check for a received nick + if not remote_nick and config.get('enable_user_nick'): + if message.xml.find('{http://jabber.org/protocol/nick}nick') is not None: + remote_nick = message['nick']['nick'] + if not remote_nick: + remote_nick = conv_jid.user + if not remote_nick: + remote_nick = conv_jid.full + own = False + # we wrote the message (happens with carbons) + elif message['from'].bare == self.xmpp.boundjid.bare: + conv_jid = message['to'] + jid = self.xmpp.boundjid + color = get_theme().COLOR_OWN_NICK + remote_nick = self.own_nick + own = True + # we are not part of that message, drop it + else: + return + + conversation = self.get_conversation_by_jid(conv_jid, create=True) + if isinstance(conversation, tabs.DynamicConversationTab) and conv_jid.resource: + conversation.lock(conv_jid.resource) + + if not own and not conversation.nick: + conversation.nick = remote_nick + elif not own: # keep a fixed nick during the whole conversation + remote_nick = conversation.nick + + self.events.trigger('conversation_msg', message, conversation) + if not message['body']: + return + body = xhtml.get_body_from_message_stanza(message, use_xhtml=use_xhtml, + tmp_dir=tmp_dir, + extract_images=extract_images) + delayed, date = common.find_delayed_tag(message) + + def try_modify(): + replaced_id = message['replace']['id'] + if replaced_id and config.get_by_tabname('group_corrections', + conv_jid.bare): + try: + conversation.modify_message(body, replaced_id, message['id'], jid=jid, + nickname=remote_nick) + return True + except CorrectionError: + log.debug('Unable to correct a message', exc_info=True) + return False + + if not try_modify(): + conversation.add_message(body, date, + nickname=remote_nick, + nick_color=color, + history=delayed, + identifier=message['id'], + jid=jid, + typ=1) + + if conversation.remote_wants_chatstates is None and not delayed: + if message['chat_state']: + conversation.remote_wants_chatstates = True + else: + conversation.remote_wants_chatstates = False + if not own and 'private' in config.get('beep_on').split(): + if not config.get_by_tabname('disable_beep', conv_jid.bare): + curses.beep() + if self.current_tab() is not conversation: + if not own: + conversation.state = 'private' + self.refresh_tab_win() + else: + conversation.set_state('normal') + self.refresh_tab_win() + else: + self.refresh_window() + +def on_nick_received(self, message): + """ + Called when a pep notification for an user nickname + is received + """ + contact = roster[message['from'].bare] + if not contact: + return + item = message['pubsub_event']['items']['item'] + if item.xml.find('{http://jabber.org/protocol/nick}nick'): + contact.name = item['nick']['nick'] + else: + contact.name = '' + +def on_gaming_event(self, message): + """ + Called when a pep notification for user gaming + is received + """ + contact = roster[message['from'].bare] + if not contact: + return + item = message['pubsub_event']['items']['item'] + old_gaming = contact.gaming + if item.xml.find('{urn:xmpp:gaming:0}gaming'): + item = item['gaming'] + # only name and server_address are used for now + contact.gaming = { + 'character_name': item['character_name'], + 'character_profile': item['character_profile'], + 'name': item['name'], + 'level': item['level'], + 'uri': item['uri'], + 'server_name': item['server_name'], + 'server_address': item['server_address'], + } + else: + contact.gaming = {} + + if contact.gaming: + logger.log_roster_change(contact.bare_jid, 'is playing %s' % (common.format_gaming_string(contact.gaming))) + + if old_gaming != contact.gaming and config.get_by_tabname('display_gaming_notifications', contact.bare_jid): + if contact.gaming: + self.information('%s is playing %s' % (contact.bare_jid, common.format_gaming_string(contact.gaming)), 'Gaming') + else: + self.information(contact.bare_jid + ' stopped playing.', 'Gaming') + +def on_mood_event(self, message): + """ + Called when a pep notification for an user mood + is received. + """ + contact = roster[message['from'].bare] + if not contact: + return + roster.modified() + item = message['pubsub_event']['items']['item'] + old_mood = contact.mood + if item.xml.find('{http://jabber.org/protocol/mood}mood'): + mood = item['mood']['value'] + if mood: + mood = pep.MOODS.get(mood, mood) + text = item['mood']['text'] + if text: + mood = '%s (%s)' % (mood, text) + contact.mood = mood + else: + contact.mood = '' + else: + contact.mood = '' + + if contact.mood: + logger.log_roster_change(contact.bare_jid, 'has now the mood: %s' % contact.mood) + + if old_mood != contact.mood and config.get_by_tabname('display_mood_notifications', contact.bare_jid): + if contact.mood: + self.information('Mood from '+ contact.bare_jid + ': ' + contact.mood, 'Mood') + else: + self.information(contact.bare_jid + ' stopped having his/her mood.', 'Mood') + +def on_activity_event(self, message): + """ + Called when a pep notification for an user activity + is received. + """ + contact = roster[message['from'].bare] + if not contact: + return + roster.modified() + item = message['pubsub_event']['items']['item'] + old_activity = contact.activity + if item.xml.find('{http://jabber.org/protocol/activity}activity'): + try: + activity = item['activity']['value'] + except ValueError: + return + if activity[0]: + general = pep.ACTIVITIES.get(activity[0]) + s = general['category'] + if activity[1]: + s = s + '/' + general.get(activity[1], 'other') + text = item['activity']['text'] + if text: + s = '%s (%s)' % (s, text) + contact.activity = s + else: + contact.activity = '' + else: + contact.activity = '' + + if contact.activity: + logger.log_roster_change(contact.bare_jid, 'has now the activity %s' % contact.activity) + + if old_activity != contact.activity and config.get_by_tabname('display_activity_notifications', contact.bare_jid): + if contact.activity: + self.information('Activity from '+ contact.bare_jid + ': ' + contact.activity, 'Activity') + else: + self.information(contact.bare_jid + ' stopped doing his/her activity.', 'Activity') + +def on_tune_event(self, message): + """ + Called when a pep notification for an user tune + is received + """ + contact = roster[message['from'].bare] + if not contact: + return + roster.modified() + item = message['pubsub_event']['items']['item'] + old_tune = contact.tune + if item.xml.find('{http://jabber.org/protocol/tune}tune'): + item = item['tune'] + contact.tune = { + 'artist': item['artist'], + 'length': item['length'], + 'rating': item['rating'], + 'source': item['source'], + 'title': item['title'], + 'track': item['track'], + 'uri': item['uri'] + } + else: + contact.tune = {} + + if contact.tune: + logger.log_roster_change(message['from'].bare, 'is now listening to %s' % common.format_tune_string(contact.tune)) + + if old_tune != contact.tune and config.get_by_tabname('display_tune_notifications', contact.bare_jid): + if contact.tune: + self.information( + 'Tune from '+ message['from'].bare + ': ' + common.format_tune_string(contact.tune), + 'Tune') + else: + self.information(contact.bare_jid + ' stopped listening to music.', 'Tune') + +def on_groupchat_message(self, message): + """ + Triggered whenever a message is received from a multi-user chat room. + """ + if message['subject']: + return + room_from = message['from'].bare + + if message['type'] == 'error': # Check if it's an error + return self.room_error(message, room_from) + + tab = self.get_tab_by_name(room_from, tabs.MucTab) + if not tab: + self.information("message received for a non-existing room: %s" % (room_from)) + muc.leave_groupchat(self.xmpp, room_from, self.own_nick, msg='') + return + + nick_from = message['mucnick'] + user = tab.get_user_by_name(nick_from) + if user and user in tab.ignores: + return + + self.events.trigger('muc_msg', message, tab) + use_xhtml = config.get('enable_xhtml_im') + tmp_dir = config.get('tmp_image_dir') or path.join(CACHE_DIR, 'images') + extract_images = config.get('extract_inline_images') + body = xhtml.get_body_from_message_stanza(message, use_xhtml=use_xhtml, + tmp_dir=tmp_dir, + extract_images=extract_images) + if not body: + return + + old_state = tab.state + delayed, date = common.find_delayed_tag(message) + replaced_id = message['replace']['id'] + replaced = False + if replaced_id is not '' and config.get_by_tabname('group_corrections', + message['from'].bare): + try: + delayed_date = date or datetime.now() + if tab.modify_message(body, replaced_id, message['id'], + time=delayed_date, + nickname=nick_from, user=user): + self.events.trigger('highlight', message, tab) + replaced = True + except CorrectionError: + log.debug('Unable to correct a message', exc_info=True) + if not replaced and tab.add_message(body, date, nick_from, history=delayed, identifier=message['id'], jid=message['from'], typ=1): + self.events.trigger('highlight', message, tab) + + if message['from'].resource == tab.own_nick: + tab.last_sent_message = message + + if tab is self.current_tab(): + tab.text_win.refresh() + tab.info_header.refresh(tab, tab.text_win) + tab.input.refresh() + self.doupdate() + elif tab.state != old_state: + self.refresh_tab_win() + current = self.current_tab() + if hasattr(current, 'input') and current.input: + current.input.refresh() + self.doupdate() + + if 'message' in config.get('beep_on').split(): + if (not config.get_by_tabname('disable_beep', room_from) + and self.own_nick != message['from'].resource): + curses.beep() + +def on_muc_own_nickchange(self, muc): + "We changed our nick in a MUC" + for tab in self.get_tabs(tabs.PrivateTab): + if tab.parent_muc == muc: + tab.own_nick = muc.own_nick + +def on_groupchat_private_message(self, message): + """ + We received a Private Message (from someone in a Muc) + """ + jid = message['from'] + nick_from = jid.resource + if not nick_from: + return self.on_groupchat_message(message) + + room_from = jid.bare + use_xhtml = config.get('enable_xhtml_im') + tmp_dir = config.get('tmp_image_dir') or path.join(CACHE_DIR, 'images') + extract_images = config.get('extract_inline_images') + body = xhtml.get_body_from_message_stanza(message, use_xhtml=use_xhtml, + tmp_dir=tmp_dir, + extract_images=extract_images) + tab = self.get_tab_by_name(jid.full, tabs.PrivateTab) # get the tab with the private conversation + ignore = config.get_by_tabname('ignore_private', room_from) + if not tab: # It's the first message we receive: create the tab + if body and not ignore: + tab = self.open_private_window(room_from, nick_from, False) + if ignore: + self.events.trigger('ignored_private', message, tab) + msg = config.get_by_tabname('private_auto_response', room_from) + if msg and body: + self.xmpp.send_message(mto=jid.full, mbody=msg, mtype='chat') + return + self.events.trigger('private_msg', message, tab) + body = xhtml.get_body_from_message_stanza(message, use_xhtml=use_xhtml, + tmp_dir=tmp_dir, + extract_images=extract_images) + if not body or not tab: + return + replaced_id = message['replace']['id'] + replaced = False + user = tab.parent_muc.get_user_by_name(nick_from) + if replaced_id is not '' and config.get_by_tabname('group_corrections', + room_from): + try: + tab.modify_message(body, replaced_id, message['id'], user=user, jid=message['from'], + nickname=nick_from) + replaced = True + except CorrectionError: + log.debug('Unable to correct a message', exc_info=True) + if not replaced: + tab.add_message(body, time=None, nickname=nick_from, + forced_user=user, + identifier=message['id'], + jid=message['from'], + typ=1) + + if tab.remote_wants_chatstates is None: + if message['chat_state']: + tab.remote_wants_chatstates = True + else: + tab.remote_wants_chatstates = False + if 'private' in config.get('beep_on').split(): + if not config.get_by_tabname('disable_beep', jid.full): + curses.beep() + if tab is self.current_tab(): + self.refresh_window() + else: + tab.state = 'private' + self.refresh_tab_win() + +### Chatstates ### + +def on_chatstate_active(self, message): + self.on_chatstate(message, "active") + +def on_chatstate_inactive(self, message): + self.on_chatstate(message, "inactive") + +def on_chatstate_composing(self, message): + self.on_chatstate(message, "composing") + +def on_chatstate_paused(self, message): + self.on_chatstate(message, "paused") + +def on_chatstate_gone(self, message): + self.on_chatstate(message, "gone") + +def on_chatstate(self, message, state): + if message['type'] == 'chat': + if not self.on_chatstate_normal_conversation(message, state): + tab = self.get_tab_by_name(message['from'].full, tabs.PrivateTab) + if not tab: + return + self.on_chatstate_private_conversation(message, state) + elif message['type'] == 'groupchat': + self.on_chatstate_groupchat_conversation(message, state) + +def on_chatstate_normal_conversation(self, message, state): + tab = self.get_conversation_by_jid(message['from'], False) + if not tab: + return False + tab.remote_wants_chatstates = True + self.events.trigger('normal_chatstate', message, tab) + tab.chatstate = state + if state == 'gone' and isinstance(tab, tabs.DynamicConversationTab): + tab.unlock() + if tab == self.current_tab(): + tab.refresh_info_header() + self.doupdate() + else: + _composing_tab_state(tab, state) + self.refresh_tab_win() + return True + +def on_chatstate_private_conversation(self, message, state): + """ + Chatstate received in a private conversation from a MUC + """ + tab = self.get_tab_by_name(message['from'].full, tabs.PrivateTab) + if not tab: + return + tab.remote_wants_chatstates = True + self.events.trigger('private_chatstate', message, tab) + tab.chatstate = state + if tab == self.current_tab(): + tab.refresh_info_header() + self.doupdate() + else: + _composing_tab_state(tab, state) + self.refresh_tab_win() + return True + +def on_chatstate_groupchat_conversation(self, message, state): + """ + Chatstate received in a MUC + """ + nick = message['mucnick'] + room_from = message.get_mucroom() + tab = self.get_tab_by_name(room_from, tabs.MucTab) + if tab and tab.get_user_by_name(nick): + self.events.trigger('muc_chatstate', message, tab) + tab.get_user_by_name(nick).chatstate = state + if tab == self.current_tab(): + if not self.size.tab_degrade_x: + tab.user_win.refresh(tab.users) + tab.input.refresh() + self.doupdate() + else: + _composing_tab_state(tab, state) + self.refresh_tab_win() + +### subscription-related handlers ### + +def on_roster_update(self, iq): + """ + The roster was received. + """ + for item in iq['roster']: + try: + jid = item['jid'] + except InvalidJID: + jid = item._get_attr('jid', '') + log.error('Invalid JID: "%s"', jid, exc_info=True) + else: + if item['subscription'] == 'remove': + del roster[jid] + else: + roster.update_contact_groups(jid) + roster.update_size() + if isinstance(self.current_tab(), tabs.RosterInfoTab): + self.refresh_window() + +def on_subscription_request(self, presence): + """subscribe received""" + jid = presence['from'].bare + contact = roster[jid] + if contact and contact.subscription in ('from', 'both'): + return + elif contact and contact.subscription == 'to': + self.xmpp.sendPresence(pto=jid, ptype='subscribed') + self.xmpp.sendPresence(pto=jid) + else: + if not contact: + contact = roster.get_and_set(jid) + roster.update_contact_groups(contact) + contact.pending_in = True + self.information('%s wants to subscribe to your presence, use ' + '/accept <jid> or /deny <jid> in the roster ' + 'tab to accept or reject the query.' % jid, + 'Roster') + self.get_tab_by_number(0).state = 'highlight' + roster.modified() + if isinstance(self.current_tab(), tabs.RosterInfoTab): + self.refresh_window() + +def on_subscription_authorized(self, presence): + """subscribed received""" + jid = presence['from'].bare + contact = roster[jid] + if contact.subscription not in ('both', 'from'): + self.information('%s accepted your contact proposal' % jid, 'Roster') + if contact.pending_out: + contact.pending_out = False + + roster.modified() + + if isinstance(self.current_tab(), tabs.RosterInfoTab): + self.refresh_window() + +def on_subscription_remove(self, presence): + """unsubscribe received""" + jid = presence['from'].bare + contact = roster[jid] + if not contact: + return + roster.modified() + self.information('%s does not want to receive your status anymore.' % jid, 'Roster') + self.get_tab_by_number(0).state = 'highlight' + if isinstance(self.current_tab(), tabs.RosterInfoTab): + self.refresh_window() + +def on_subscription_removed(self, presence): + """unsubscribed received""" + jid = presence['from'].bare + contact = roster[jid] + if not contact: + return + roster.modified() + if contact.pending_out: + self.information('%s rejected your contact proposal' % jid, 'Roster') + contact.pending_out = False + else: + self.information('%s does not want you to receive his/her/its status anymore.'%jid, 'Roster') + self.get_tab_by_number(0).state = 'highlight' + if isinstance(self.current_tab(), tabs.RosterInfoTab): + self.refresh_window() + +### Presence-related handlers ### + +def on_presence(self, presence): + if presence.match('presence/muc') or presence.xml.find('{http://jabber.org/protocol/muc#user}x'): + return + jid = presence['from'] + contact = roster[jid.bare] + tab = self.get_conversation_by_jid(jid, create=False) + if isinstance(tab, tabs.DynamicConversationTab): + if tab.get_dest_jid() != jid.full: + tab.unlock(from_=jid.full) + elif presence['type'] == 'unavailable': + tab.unlock() + if contact is None: + return + roster.modified() + contact.error = None + self.events.trigger('normal_presence', presence, contact[jid.full]) + tab = self.get_conversation_by_jid(jid, create=False) + if isinstance(self.current_tab(), tabs.RosterInfoTab): + self.refresh_window() + elif self.current_tab() == tab: + tab.refresh() + self.doupdate() + +def on_presence_error(self, presence): + jid = presence['from'] + contact = roster[jid.bare] + if not contact: + return + roster.modified() + contact.error = presence['error']['type'] + ': ' + presence['error']['condition'] + # reset chat states status on presence error + tab = self.get_tab_by_name(jid.full, tabs.ConversationTab) + if tab: + tab.remote_wants_chatstates = None + +def on_got_offline(self, presence): + """ + A JID got offline + """ + if presence.match('presence/muc') or presence.xml.find('{http://jabber.org/protocol/muc#user}x'): + return + jid = presence['from'] + if not logger.log_roster_change(jid.bare, 'got offline'): + self.information('Unable to write in the log file', 'Error') + # If a resource got offline, display the message in the conversation with this + # precise resource. + contact = roster[jid.bare] + name = jid.bare + if contact: + roster.connected -= 1 + if contact.name: + name = contact.name + if jid.resource: + self.add_information_message_to_conversation_tab(jid.full, '\x195}%s is \x191}offline' % name) + self.add_information_message_to_conversation_tab(jid.bare, '\x195}%s is \x191}offline' % name) + self.information('\x193}%s \x195}is \x191}offline' % name, 'Roster') + roster.modified() + if isinstance(self.current_tab(), tabs.RosterInfoTab): + self.refresh_window() + +def on_got_online(self, presence): + """ + A JID got online + """ + if presence.match('presence/muc') or presence.xml.find('{http://jabber.org/protocol/muc#user}x'): + return + jid = presence['from'] + contact = roster[jid.bare] + if contact is None: + # Todo, handle presence coming from contacts not in roster + return + roster.connected += 1 + roster.modified() + if not logger.log_roster_change(jid.bare, 'got online'): + self.information('Unable to write in the log file', 'Error') + resource = Resource(jid.full, { + 'priority': presence.get_priority() or 0, + 'status': presence['status'], + 'show': presence['show'], + }) + self.events.trigger('normal_presence', presence, resource) + name = contact.name if contact.name else jid.bare + self.add_information_message_to_conversation_tab(jid.full, '\x195}%s is \x194}online' % name) + if time.time() - self.connection_time > 10: + # We do not display messages if we recently logged in + if presence['status']: + self.information("\x193}%s \x195}is \x194}online\x195} (\x19o%s\x195})" % (name, presence['status']), "Roster") + else: + self.information("\x193}%s \x195}is \x194}online\x195}" % name, "Roster") + self.add_information_message_to_conversation_tab(jid.bare, '\x195}%s is \x194}online' % name) + if isinstance(self.current_tab(), tabs.RosterInfoTab): + self.refresh_window() + +def on_groupchat_presence(self, presence): + """ + Triggered whenever a presence stanza is received from a user in a multi-user chat room. + Display the presence on the room window and update the + presence information of the concerned user + """ + from_room = presence['from'].bare + tab = self.get_tab_by_name(from_room, tabs.MucTab) + if tab: + self.events.trigger('muc_presence', presence, tab) + tab.handle_presence(presence) + + +### Connection-related handlers ### + +def on_failed_connection(self, error): + """ + We cannot contact the remote server + """ + self.information("Connection to remote server failed: %s" % (error,), 'Error') + +def on_disconnected(self, event): + """ + When we are disconnected from remote server + """ + roster.connected = 0 + # Stop the ping plugin. It would try to send stanza on regular basis + self.xmpp.plugin['xep_0199'].disable_keepalive() + roster.modified() + for tab in self.get_tabs(tabs.MucTab): + tab.disconnect() + msg_typ = 'Error' if not self.legitimate_disconnect else 'Info' + self.information("Disconnected from server.", msg_typ) + if not self.legitimate_disconnect and config.get('auto_reconnect', True): + self.information("Auto-reconnecting.", 'Info') + self.xmpp.start() + +def on_stream_error(self, event): + """ + When we receive a stream error + """ + if event and event['text']: + self.information('Stream error: %s' % event['text'], 'Error') + +def on_failed_all_auth(self, event): + """ + Authentication failed + """ + self.information("Authentication failed (bad credentials?).", + 'Error') + self.legitimate_disconnect = True + +def on_no_auth(self, event): + """ + Authentication failed (no mech) + """ + self.information("Authentication failed, no login method available.", + 'Error') + self.legitimate_disconnect = True + +def on_connected(self, event): + """ + Remote host responded, but we are not yet authenticated + """ + self.information("Connected to server.", 'Info') + +def on_connecting(self, event): + """ + Just before we try to connect to the server + """ + self.legitimate_disconnect = False + +def on_session_start(self, event): + """ + Called when we are connected and authenticated + """ + self.connection_time = time.time() + if not self.plugins_autoloaded: # Do not reload plugins on reconnection + self.autoload_plugins() + self.information("Authentication success.", 'Info') + self.information("Your JID is %s" % self.xmpp.boundjid.full, 'Info') + if not self.xmpp.anon: + # request the roster + self.xmpp.get_roster() + roster.update_contact_groups(self.xmpp.boundjid.bare) + # send initial presence + if config.get('send_initial_presence'): + pres = self.xmpp.make_presence() + pres['show'] = self.status.show + pres['status'] = self.status.message + self.events.trigger('send_normal_presence', pres) + pres.send() + self.bookmarks.get_local() + # join all the available bookmarks. As of yet, this is just the local ones + _join_initial_rooms(self, self.bookmarks) + + if config.get('enable_user_nick'): + self.xmpp.plugin['xep_0172'].publish_nick(nick=self.own_nick, callback=dumb_callback) + asyncio.async(self.xmpp.plugin['xep_0115'].update_caps()) + # Start the ping's plugin regular event + self.xmpp.set_keepalive_values() + +### Other handlers ### + +def on_status_codes(self, message): + """ + Handle groupchat messages with status codes. + Those are received when a room configuration change occurs. + """ + room_from = message['from'] + tab = self.get_tab_by_name(room_from, tabs.MucTab) + status_codes = set([s.attrib['code'] for s in message.findall('{%s}x/{%s}status' % (tabs.NS_MUC_USER, tabs.NS_MUC_USER))]) + if '101' in status_codes: + self.information('Your affiliation in the room %s changed' % room_from, 'Info') + elif tab and status_codes: + show_unavailable = '102' in status_codes + hide_unavailable = '103' in status_codes + non_priv = '104' in status_codes + logging_on = '170' in status_codes + logging_off = '171' in status_codes + non_anon = '172' in status_codes + semi_anon = '173' in status_codes + full_anon = '174' in status_codes + modif = False + if show_unavailable or hide_unavailable or non_priv or logging_off\ + or non_anon or semi_anon or full_anon: + tab.add_message('\x19%(info_col)s}Info: A configuration change not privacy-related occured.' % + {'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)}, + typ=2) + modif = True + if show_unavailable: + tab.add_message('\x19%(info_col)s}Info: The unavailable members are now shown.' % + {'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)}, + typ=2) + elif hide_unavailable: + tab.add_message('\x19%(info_col)s}Info: The unavailable members are now hidden.' % + {'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)}, + typ=2) + if non_anon: + tab.add_message('\x191}Warning:\x19%(info_col)s} The room is now not anonymous. (public JID)' % + {'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)}, + typ=2) + elif semi_anon: + tab.add_message('\x19%(info_col)s}Info: The room is now semi-anonymous. (moderators-only JID)' % + {'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)}, + typ=2) + elif full_anon: + tab.add_message('\x19%(info_col)s}Info: The room is now fully anonymous.' % + {'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)}, + typ=2) + if logging_on: + tab.add_message('\x191}Warning: \x19%(info_col)s}This room is publicly logged' % + {'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)}, + typ=2) + elif logging_off: + tab.add_message('\x19%(info_col)s}Info: This room is not logged anymore.' % + {'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)}, + typ=2) + if modif: + self.refresh_window() + +def on_groupchat_subject(self, message): + """ + Triggered when the topic is changed. + """ + nick_from = message['mucnick'] + room_from = message.get_mucroom() + tab = self.get_tab_by_name(room_from, tabs.MucTab) + subject = message['subject'] + if subject is None or not tab: + return + if subject != tab.topic: + # Do not display the message if the subject did not change or if we + # receive an empty topic when joining the room. + if nick_from: + tab.add_message("\x19%(info_col)s}%(nick)s set the subject to: %(subject)s" % + {'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT), 'nick':nick_from, 'subject':subject}, + time=None, + typ=2) + else: + tab.add_message("\x19%(info_col)s}The subject is: %(subject)s" % + {'subject':subject, 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)}, + time=None, + typ=2) + tab.topic = subject + tab.topic_from = nick_from + if self.get_tab_by_name(room_from, tabs.MucTab) is self.current_tab(): + self.refresh_window() + +def on_receipt(self, message): + """ + When a delivery receipt is received (XEP-0184) + """ + jid = message['from'] + msg_id = message['receipt'] + if not msg_id: + return + + conversation = self.get_tab_by_name(jid, tabs.ChatTab) + conversation = conversation or self.get_tab_by_name(jid.bare, tabs.ChatTab) + if not conversation: + return + + try: + conversation.ack_message(msg_id, self.xmpp.boundjid) + except AckError: + log.debug('Error while receiving an ack', exc_info=True) + +def on_data_form(self, message): + """ + When a data form is received + """ + self.information('%s' % message) + +def on_attention(self, message): + """ + Attention probe received. + """ + jid_from = message['from'] + self.information('%s requests your attention!' % jid_from, 'Info') + for tab in self.tabs: + if tab.name == jid_from: + tab.state = 'attention' + self.refresh_tab_win() + return + for tab in self.tabs: + if tab.name == jid_from.bare: + tab.state = 'attention' + self.refresh_tab_win() + return + self.information('%s tab not found.' % jid_from, 'Error') + +def room_error(self, error, room_name): + """ + Display the error in the tab + """ + tab = self.get_tab_by_name(room_name, tabs.MucTab) + if not tab: + return + error_message = self.get_error_message(error) + tab.add_message(error_message, highlight=True, nickname='Error', + nick_color=get_theme().COLOR_ERROR_MSG, typ=2) + code = error['error']['code'] + if code == '401': + msg = 'To provide a password in order to join the room, type "/join / password" (replace "password" by the real password)' + tab.add_message(msg, typ=2) + if code == '409': + if config.get('alternative_nickname') != '': + self.command_join('%s/%s'% (tab.name, tab.own_nick+config.get('alternative_nickname'))) + else: + if not tab.joined: + tab.add_message('You can join the room with an other nick, by typing "/join /other_nick"', typ=2) + self.refresh_window() + +def outgoing_stanza(self, stanza): + """ + We are sending a new stanza, write it in the xml buffer if needed. + """ + if self.xml_tab: + if PYGMENTS: + xhtml_text = highlight('%s' % stanza, LEXER, FORMATTER) + poezio_colored = xhtml.xhtml_to_poezio_colors(xhtml_text, force=True).rstrip('\x19o').strip() + else: + poezio_colored = '%s' % stanza + self.add_message_to_text_buffer(self.xml_buffer, poezio_colored, + nickname=get_theme().CHAR_XML_OUT) + try: + if self.xml_tab.match_stanza(ElementBase(ET.fromstring(stanza))): + self.add_message_to_text_buffer(self.xml_tab.filtered_buffer, poezio_colored, + nickname=get_theme().CHAR_XML_OUT) + except: + log.debug('', exc_info=True) + + if isinstance(self.current_tab(), tabs.XMLTab): + self.current_tab().refresh() + self.doupdate() + +def incoming_stanza(self, stanza): + """ + We are receiving a new stanza, write it in the xml buffer if needed. + """ + if self.xml_tab: + if PYGMENTS: + xhtml_text = highlight('%s' % stanza, LEXER, FORMATTER) + poezio_colored = xhtml.xhtml_to_poezio_colors(xhtml_text, force=True).rstrip('\x19o').strip() + else: + poezio_colored = '%s' % stanza + self.add_message_to_text_buffer(self.xml_buffer, poezio_colored, + nickname=get_theme().CHAR_XML_IN) + try: + if self.xml_tab.match_stanza(stanza): + self.add_message_to_text_buffer(self.xml_tab.filtered_buffer, poezio_colored, + nickname=get_theme().CHAR_XML_IN) + except: + log.debug('', exc_info=True) + if isinstance(self.current_tab(), tabs.XMLTab): + self.current_tab().refresh() + self.doupdate() + +def ssl_invalid_chain(self, tb): + self.information('The certificate sent by the server is invalid.', 'Error') + self.disconnect() + +def validate_ssl(self, pem): + """ + Check the server certificate using the slixmpp ssl_cert event + """ + if config.get('ignore_certificate'): + return + cert = config.get('certificate') + # update the cert representation when it uses the old one + if cert and not ':' in cert: + cert = ':'.join(i + j for i, j in zip(cert[::2], cert[1::2])).upper() + config.set_and_save('certificate', cert) + + der = ssl.PEM_cert_to_DER_cert(pem) + sha1_digest = sha1(der).hexdigest().upper() + sha1_found_cert = ':'.join(i + j for i, j in zip(sha1_digest[::2], sha1_digest[1::2])) + sha2_digest = sha512(der).hexdigest().upper() + sha2_found_cert = ':'.join(i + j for i, j in zip(sha2_digest[::2], sha2_digest[1::2])) + if cert: + if sha1_found_cert == cert: + log.debug('Cert %s OK', sha1_found_cert) + log.debug('Current hash is SHA-1, moving to SHA-2 (%s)', + sha2_found_cert) + config.set_and_save('certificate', sha2_found_cert) + return + elif sha2_found_cert == cert: + log.debug('Cert %s OK', sha2_found_cert) + return + else: + saved_input = self.current_tab().input + log.debug('\nWARNING: CERTIFICATE CHANGED old: %s, new: %s\n', cert, sha2_found_cert) + self.information('New certificate found (sha-2 hash:' + ' %s)\nPlease validate or abort' % sha2_found_cert, + 'Warning') + def check_input(): + self.current_tab().input = saved_input + if input.value: + self.information('Setting new certificate: old: %s, new: %s' % (cert, sha2_found_cert), 'Info') + log.debug('Setting certificate to %s', sha2_found_cert) + if not config.silent_set('certificate', sha2_found_cert): + self.information('Unable to write in the config file', 'Error') + else: + self.information('You refused to validate the certificate. You are now disconnected', 'Info') + self.disconnect() + new_loop.stop() + asyncio.set_event_loop(old_loop) + input = windows.YesNoInput(text="WARNING! Server certificate has changed, accept? (y/n)", callback=check_input) + self.current_tab().input = input + input.resize(1, self.current_tab().width, self.current_tab().height-1, 0) + input.refresh() + self.doupdate() + old_loop = asyncio.get_event_loop() + new_loop = asyncio.new_event_loop() + asyncio.set_event_loop(new_loop) + new_loop.add_reader(sys.stdin, self.on_input_readable) + curses.beep() + new_loop.run_forever() + else: + log.debug('First time. Setting certificate to %s', sha2_found_cert) + if not config.silent_set('certificate', sha2_found_cert): + self.information('Unable to write in the config file', 'Error') + +def _composing_tab_state(tab, state): + """ + Set a tab state to or from the "composing" state + according to the config and the current tab state + """ + if isinstance(tab, tabs.MucTab): + values = ('true', 'muc') + elif isinstance(tab, tabs.PrivateTab): + values = ('true', 'direct', 'private') + elif isinstance(tab, tabs.ConversationTab): + values = ('true', 'direct', 'conversation') + else: + return # should not happen + + show = config.get('show_composing_tabs') + show = show in values + + if tab.state != 'composing' and state == 'composing': + if show: + if tabs.STATE_PRIORITY[tab.state] > tabs.STATE_PRIORITY[state]: + return + tab.save_state() + tab.state = 'composing' + elif tab.state == 'composing' and state != 'composing': + tab.restore_state() + +### Ad-hoc commands + +def on_next_adhoc_step(self, iq, adhoc_session): + status = iq['command']['status'] + xform = iq.xml.find('{http://jabber.org/protocol/commands}command/{jabber:x:data}x') + if xform is not None: + form = self.xmpp.plugin['xep_0004'].buildForm(xform) + else: + form = None + + if status == 'error': + return self.information("An error occured while executing the command") + + if status == 'executing': + if not form: + self.information("Adhoc command step does not contain a data-form. Aborting the execution.", "Error") + return self.xmpp.plugin['xep_0050'].cancel_command(adhoc_session) + on_validate = self.validate_adhoc_step + on_cancel = self.cancel_adhoc_command + if status == 'completed': + on_validate = lambda form, session: self.close_tab() + on_cancel = lambda form, session: self.close_tab() + + # If a form is available, use it, and add the Notes from the + # response to it, if any + if form: + for note in iq['command']['notes']: + form.add_field(type='fixed', label=note[1]) + self.open_new_form(form, on_cancel, on_validate, + session=adhoc_session) + else: # otherwise, just display an information + # message + notes = '\n'.join([note[1] for note in iq['command']['notes']]) + self.information("Adhoc command %s: %s" % (status, notes), "Info") + +def on_adhoc_error(self, iq, adhoc_session): + self.xmpp.plugin['xep_0050'].terminate_command(adhoc_session) + error_message = self.get_error_message(iq) + self.information("An error occured while executing the command: %s" % (error_message), + 'Error') + +def cancel_adhoc_command(self, form, session): + self.xmpp.plugin['xep_0050'].cancel_command(session) + self.close_tab() + +def validate_adhoc_step(self, form, session): + session['payload'] = form + self.xmpp.plugin['xep_0050'].continue_command(session) + self.close_tab() + +def terminate_adhoc_command(self, form, session): + self.xmpp.plugin['xep_0050'].terminate_command(session) + self.close_tab() diff --git a/poezio/core/structs.py b/poezio/core/structs.py new file mode 100644 index 00000000..4ce0ef43 --- /dev/null +++ b/poezio/core/structs.py @@ -0,0 +1,49 @@ +""" +Module defining structures useful to the core class and related methods +""" +import collections + +# http://xmpp.org/extensions/xep-0045.html#errorstatus +ERROR_AND_STATUS_CODES = { + '401': 'A password is required', + '403': 'Permission denied', + '404': 'The room doesn’t exist', + '405': 'Your are not allowed to create a new room', + '406': 'A reserved nick must be used', + '407': 'You are not in the member list', + '409': 'This nickname is already in use or has been reserved', + '503': 'The maximum number of users has been reached', + } + +# http://xmpp.org/extensions/xep-0086.html +DEPRECATED_ERRORS = { + '302': 'Redirect', + '400': 'Bad request', + '401': 'Not authorized', + '402': 'Payment required', + '403': 'Forbidden', + '404': 'Not found', + '405': 'Not allowed', + '406': 'Not acceptable', + '407': 'Registration required', + '408': 'Request timeout', + '409': 'Conflict', + '500': 'Internal server error', + '501': 'Feature not implemented', + '502': 'Remote server error', + '503': 'Service unavailable', + '504': 'Remote server timeout', + '510': 'Disconnected', +} + +possible_show = {'available':None, + 'chat':'chat', + 'away':'away', + 'afk':'away', + 'dnd':'dnd', + 'busy':'dnd', + 'xa':'xa' + } + +Status = collections.namedtuple('Status', 'show message') +Command = collections.namedtuple('Command', 'func desc comp short usage') diff --git a/poezio/daemon.py b/poezio/daemon.py new file mode 100755 index 00000000..6325d8df --- /dev/null +++ b/poezio/daemon.py @@ -0,0 +1,82 @@ +#/usr/bin/env python3 +# Copyright 2011 Florent Le Coz <louiz@louiz.org> +# +# This file is part of Poezio. +# +# Poezio is free software: you can redistribute it and/or modify +# it under the terms of the zlib license. See the COPYING file. + +""" +This file is a standalone program that reads commands on +stdin and executes them (each line should be a command). + +Usage: cat some_fifo | ./daemon.py + +Poezio writes commands in the fifo, and this daemon executes them on the +local machine. +Note that you should not start this daemon if you do not trust the remote +machine that is running poezio, since this could make it run any (dangerous) +command on your local machine. +""" + +import sys +import threading +import subprocess +import shlex +import logging + +from subprocess import DEVNULL + +log = logging.getLogger(__name__) + +class Executor(threading.Thread): + """ + Just a class to execute commands in a thread. This way, the execution + can totally fail, we don’t care, and we can start commands without + having to wait for them to return. + WARNING: Be careful to properly escape what is untrusted by using + pipes.quote (or shlex.quote with python 3.3) for example. + """ + def __init__(self, command, remote=False): + threading.Thread.__init__(self) + self.command = command + self.remote = remote + # check for > or >> special case + self.filename = None + self.redirection_mode = 'w' + if len(command) >= 3: + if command[-2] in ('>', '>>'): + self.filename = command.pop(-1) + if command[-1] == '>>': + self.redirection_mode = 'a' + command.pop(-1) + + def run(self): + log.debug('executing %s', self.command) + stdout = DEVNULL + if self.filename: + try: + stdout = open(self.filename, self.redirection_mode) + except (OSError, IOError): + log.error('Could not open redirection file: %s', self.filename, exc_info=True) + return + try: + subprocess.call(self.command, stdout=stdout, stderr=DEVNULL) + except: + if self.remote: + import traceback + print(traceback.format_exc()) + else: + log.error('Could not execute %s:', self.command, exc_info=True) + +def main(): + while True: + line = sys.stdin.readline() + if line == '': + break + command = shlex.split(line) + e = Executor(command, remote=True) + e.start() + +if __name__ == '__main__': + main() diff --git a/poezio/decorators.py b/poezio/decorators.py new file mode 100644 index 00000000..c4ea6563 --- /dev/null +++ b/poezio/decorators.py @@ -0,0 +1,139 @@ +""" +Module containing various decorators +""" + +import common + +class RefreshWrapper(object): + def __init__(self): + self.core = None + + def conditional(self, func): + """ + Decorator to refresh the UI if the wrapped function + returns True + """ + def wrap(*args, **kwargs): + ret = func(*args, **kwargs) + if self.core and ret: + self.core.refresh_window() + return ret + return wrap + + def always(self, func): + """ + Decorator that refreshs the UI no matter what after the function + """ + def wrap(*args, **kwargs): + ret = func(*args, **kwargs) + if self.core: + self.core.refresh_window() + return ret + return wrap + + def update(self, func): + """ + Decorator that only updates the screen + """ + def wrap(*args, **kwargs): + ret = func(*args, **kwargs) + if self.core: + self.core.doupdate() + return ret + return wrap + +refresh_wrapper = RefreshWrapper() + +class CommandArgParser(object): + """Modify the string argument of the function into a list of strings + containing the right number of extracted arguments, or None if we don’t + have enough. + """ + @staticmethod + def raw(func): + """Just call the function with a single string, which is the original string + untouched + """ + def wrap(self, args, *a, **kw): + return func(self, args, *a, **kw) + return wrap + + @staticmethod + def ignored(func): + """ + Call the function without any argument + """ + def wrap(self, args, *a, **kw): + return func(self, *a, **kw) + return wrap + + @staticmethod + def quoted(mandatory, optional=0, defaults=[], + ignore_trailing_arguments=False): + + """The function receives a list with a number of arguments that is between + the numbers `mandatory` and `optional`. + + If the string doesn’t contain at least `mandatory` arguments, we return + None because the given arguments are invalid. + + If there are any remaining arguments after `mandatory` and `optional` + arguments have been found (and “ignore_trailing_arguments" is not True), + we happen them to the last argument of the list. + + An argument is a string (with or without whitespaces) between to quotes + ("), or a whitespace separated word (if not inside quotes). + + The argument `defaults` is a list of strings that are used when an + optional argument is missing. For example if we accept one optional + argument, zero is available but we have one value in the `defaults` + list, we use that string inplace. The `defaults` list can only + replace missing optional arguments, not mandatory ones. And it + should not contain more than `mandatory` values. Also you cannot + + Example: + This method needs at least one argument, and accepts up to 3 + arguments + + >> @command_args_parser.quoted(1, 2, ['default for first arg'], False) + >> def f(args): + >> print(args) + + >> f('coucou les amis') # We have one mandatory and two optional + ['coucou', 'les', 'amis'] + >> f('"coucou les amis" "PROUT PROUT"') # One mandator and only one optional, + # no default for the second + ['coucou les amis', 'PROUT PROUT'] + >> f('') # Not enough args for mandatory number + None + >> f('"coucou les potes"') # One mandatory, and use the default value + # for the first optional + ['coucou les potes, 'default for first arg'] + >> f('"un et demi" deux trois quatre cinq six') # We have three trailing arguments + ['un et demi', 'deux', 'trois quatre cinq six'] + + """ + def first(func): + def second(self, args, *a, **kw): + default_args = defaults + args = common.shell_split(args) + if len(args) < mandatory: + return func(self, None, *a, **kw) + res, args = args[:mandatory], args[mandatory:] + if optional == -1: + opt_args = args[:] + else: + opt_args = args[:optional] + + if opt_args: + res += opt_args + args = args[len(opt_args):] + default_args = default_args[len(opt_args):] + res += default_args + if args and res and not ignore_trailing_arguments: + res[-1] += " " + " ".join(args) + return func(self, res, *a, **kw) + return second + return first + +command_args_parser = CommandArgParser() diff --git a/poezio/events.py b/poezio/events.py new file mode 100644 index 00000000..15ef3e35 --- /dev/null +++ b/poezio/events.py @@ -0,0 +1,87 @@ +# +# This file is part of Poezio. +# +# Poezio is free software: you can redistribute it and/or modify +# it under the terms of the zlib license. See the COPYING file. + +""" +Defines the EventHandler class. +The list of available events is here: +http://poezio.eu/doc/en/plugins.html#_poezio_events +""" + +class EventHandler(object): + """ + A class keeping a list of possible events that are triggered + by poezio. You (a plugin for example) can add an event handler + associated with an event name, and whenever that event is triggered, + the callback is called. + """ + def __init__(self): + self.events = { + 'highlight': [], + 'muc_say': [], + 'muc_say_after': [], + 'conversation_say': [], + 'conversation_say_after': [], + 'private_say': [], + 'private_say_after': [], + 'conversation_msg': [], + 'private_msg': [], + 'muc_msg': [], + 'conversation_chatstate': [], + 'muc_chatstate': [], + 'private_chatstate': [], + 'normal_presence': [], + 'muc_presence': [], + 'muc_join': [], + 'joining_muc': [], + 'changing_nick': [], + 'muc_kick': [], + 'muc_nickchange': [], + 'muc_ban': [], + 'send_normal_presence': [], + 'ignored_private': [], + 'tab_change': [], + } + + def add_event_handler(self, name, callback, position=0): + """ + Add a callback to a given event. + Note that if that event name doesn’t exist, it just returns False. + If it was successfully added, it returns True + position: 0 means insert at the beginning, -1 means end + """ + if name not in self.events: + return False + + if position >= 0: + self.events[name].insert(position, callback) + else: + self.events[name].append(callback) + + return True + + def trigger(self, name, *args, **kwargs): + """ + Call all the callbacks associated to the given event name. + """ + callbacks = self.events.get(name, None) + if callbacks is None: + return + for callback in callbacks: + callback(*args, **kwargs) + + def del_event_handler(self, name, callback): + """ + Remove the callback from the list of callbacks of the given event + """ + if not name: + for event in self.events: + while callback in self.events[event]: + self.events[event].remove(callback) + return True + else: + if callback in self.events[name]: + self.events[name].remove(callback) + diff --git a/poezio/fifo.py b/poezio/fifo.py new file mode 100644 index 00000000..863ef228 --- /dev/null +++ b/poezio/fifo.py @@ -0,0 +1,71 @@ +""" +Defines the Fifo class + +This fifo allows simple communication between a remote poezio +and a local computer, with ssh+cat. +""" + +import logging +log = logging.getLogger(__name__) + +import os +import threading + +class OpenTrick(threading.Thread): + """ + A threaded trick to make the open for writing succeed. + A fifo cannot be opened for writing if it has not been + yet opened by the other hand for reading. + So, we just open the fifo for reading and we do not close + it afterwards, because if the other reader disconnects, + we will receive a SIGPIPE. And we do not want that. + + (we never read anything from it, obviously) + """ + def __init__(self, path): + threading.Thread.__init__(self) + self.path = path + self.fd = None + + def run(self): + self.fd = open(self.path, 'r', encoding='utf-8') + + +class Fifo(object): + """ + Just a simple file handler, writing and reading in a fifo. + Mode is either 'r' or 'w', just like the mode for the open() + function. + """ + def __init__(self, path, mode): + self.trick = None + if not os.path.exists(path): + os.mkfifo(path) + if mode == 'w': + self.trick = OpenTrick(path) + # that thread will wait until we open it for writing + self.trick.start() + self.fd = open(path, mode, encoding='utf-8') + + def write(self, data): + """ + Try to write on the fifo. If that fails, this means + that nothing has that fifo opened, so the writing is useless, + so we just return (and display an error telling that, somewhere). + """ + self.fd.write(data) + self.fd.flush() + + def readline(self): + "Read a line from the fifo" + return self.fd.readline() + + def __del__(self): + "Close opened fds" + try: + self.fd.close() + if self.trick: + self.trick.fd.close() + except: + log.error('Unable to close descriptors for the fifo', + exc_info=True) diff --git a/poezio/fixes.py b/poezio/fixes.py new file mode 100644 index 00000000..3840a093 --- /dev/null +++ b/poezio/fixes.py @@ -0,0 +1,97 @@ +""" +Module used to provide fixes for slixmpp functions not yet fixed +upstream. + +TODO: Check that they are fixed and remove those hacks +""" + +from slixmpp.stanza import Message +from slixmpp.xmlstream import ET + +import logging + +log = logging.getLogger(__name__) + +def has_identity(xmpp, jid, identity, on_true=None, on_false=None): + def _cb(iq): + ident = lambda x: x[0] + res = identity in map(ident, iq['disco_info']['identities']) + if res and on_true is not None: + on_true() + if not res and on_false is not None: + on_false() + xmpp.plugin['xep_0030'].get_info(jid=jid, callback=_cb) + +def get_version(xmpp, jid, callback=None, **kwargs): + def handle_result(res): + if res and res['type'] != 'error': + ret = res['software_version'].values + else: + ret = False + if callback: + callback(ret) + return ret + iq = xmpp.make_iq_get(ito=jid) + iq['query'] = 'jabber:iq:version' + result = iq.send(callback=handle_result if callback else None) + if not callback: + return handle_result(result) + + +def get_room_form(xmpp, room, callback): + def _cb(result): + if result["type"] == "error": + return callback(None) + xform = result.xml.find('{http://jabber.org/protocol/muc#owner}query/{jabber:x:data}x') + if xform is None: + return callback(None) + form = xmpp.plugin['xep_0004'].buildForm(xform) + return callback(form) + + iq = xmpp.make_iq_get(ito=room) + query = ET.Element('{http://jabber.org/protocol/muc#owner}query') + iq.append(query) + iq.send(callback=_cb) + +def _filter_add_receipt_request(self, stanza): + """ + Auto add receipt requests to outgoing messages, if: + + - ``self.auto_request`` is set to ``True`` + - The message is not for groupchat + - The message does not contain a receipt acknowledgment + - The recipient is a bare JID or, if a full JID, one + that has the ``urn:xmpp:receipts`` feature enabled + - The message has a body + + The disco cache is checked if a full JID is specified in + the outgoing message, which may mean a round-trip disco#info + delay for the first message sent to the JID if entity caps + are not used. + """ + + if not self.auto_request: + return stanza + + if not isinstance(stanza, Message): + return stanza + + if stanza['request_receipt']: + return stanza + + if not stanza['type'] in self.ack_types: + return stanza + + if stanza['receipt']: + return stanza + + if not stanza['body']: + return stanza + + # hack + if stanza['to'].resource and not hasattr(stanza, '_add_receipt'): + return stanza + + stanza['request_receipt'] = True + return stanza + diff --git a/poezio/keyboard.py b/poezio/keyboard.py new file mode 100755 index 00000000..ccf9e752 --- /dev/null +++ b/poezio/keyboard.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +# Copyright 2010-2011 Florent Le Coz <louiz@louiz.org> +# +# This file is part of Poezio. +# +# Poezio is free software: you can redistribute it and/or modify +# it under the terms of the zlib license. See the COPYING file. + +""" +Functions to interact with the keyboard +Mainly, read keys entered and return a string (most +of the time ONE char, but may be longer if it's a keyboard +shortcut, like ^A, M-a or KEY_RESIZE) +""" + +import curses +import curses.ascii +import logging +log = logging.getLogger(__name__) + +# A callback that will handle the next key entered by the user. For +# example if the user presses Ctrl+j, we set a callbacks, and the +# next key pressed by the user will be passed to this callback +# instead of the normal process of executing global keybard +# shortcuts or inserting text in the current output. The callback +# is always reset to None afterwards (to resume the normal +# processing of keys) +continuation_keys_callback = None + +def get_next_byte(s): + """ + Read the next byte of the utf-8 char + ncurses seems to return a string of the byte + encoded in latin-1. So what we get is NOT what we typed + unless we do the conversion… + """ + try: + c = s.getkey() + except: + return (None, None) + if len(c) >= 4: + return (None, c) + return (ord(c), c.encode('latin-1')) # returns a number and a bytes object + +def get_char_list(s): + ret_list = [] + while True: + try: + key = s.get_wch() + except curses.error: + # No input, this means a timeout occurs. + return ret_list + except ValueError: # invalid input + log.debug('Invalid character entered.') + return ret_list + # Set to non-blocking. We try to read more bytes. If there are no + # more data to read, it will immediately timeout and return with the + # data we have so far + s.timeout(0) + if isinstance(key, int): + ret_list.append(curses.keyname(key).decode()) + else: + if curses.ascii.isctrl(key): + key = curses.unctrl(key).decode() + # Here special cases for alt keys, where we get a ^[ and then a second char + if key == '^[': + try: + part = s.get_wch() + if part == '[': + # CTRL+arrow and meta+arrow keys have a long format + part += s.get_wch() + s.get_wch() + s.get_wch() + s.get_wch() + except curses.error: + pass + except ValueError: # invalid input + log.debug('Invalid character entered.') + else: + key = 'M-%s' % part + # and an even more special case for keys like + # ctrl+arrows, where we get ^[, then [, then a third + # char. + if key == 'M-[': + try: + part = s.get_wch() + except curses.error: + pass + except ValueError: + log.debug('Invalid character entered.') + else: + key = '%s-%s' % (key, part) + if key == '\x7f' or key == '\x08': + key = '^?' + elif key == '\r': + key = '^M' + ret_list.append(key) + +class Keyboard(object): + def __init__(self): + self.escape = False + + def escape_next_key(self): + """ + The next key pressed by the user should be escaped. e.g. if the user + presses ^N, keyboard.get_user_input() will return ["^", "N"] instead + of ["^N"]. This will display ^N in the input, instead of + interpreting the key binding. + """ + self.escape = True + + def get_user_input(self, s): + """ + Returns a list of all the available characters to read (for example it + may contain a whole text if there’s some lag, or the user pasted text, + or the user types really really fast). Also it can return None, meaning + that it’s time to do some other checks (because this function is + blocking, we need to get out of it every now and then even if nothing + was entered). + """ + # Disable the timeout + s.timeout(-1) + ret_list = get_char_list(s) + if not ret_list: + return ret_list + if len(ret_list) != 1: + if ret_list[-1] == '^M': + ret_list.pop(-1) + ret_list = [char if char != '^M' else '^J' for char in ret_list] + if self.escape: + # Modify the first char of the list into its escaped version (i.e one or more char) + key = ret_list.pop(0) + for char in key[::-1]: + ret_list.insert(0, char) + self.escape = False + return ret_list + +if __name__ == '__main__': + import sys + keyboard = Keyboard() + s = curses.initscr() + curses.noecho() + curses.cbreak() + s.keypad(1) + curses.start_color() + curses.use_default_colors() + curses.init_pair(1, 2, -1) + s.attron(curses.A_BOLD | curses.color_pair(1)) + s.addstr('Type Ctrl-c to close\n') + s.attroff(curses.A_BOLD | curses.color_pair(1)) + + pressed_chars = [] + while True: + + try: + chars = keyboard.get_user_input(s) + for char in chars if chars else '': + s.addstr('%s ' % (char)) + pressed_chars.append(chars) + + except KeyboardInterrupt: + break + curses.echo() + curses.cbreak() + curses.curs_set(1) + curses.endwin() + for char_list in pressed_chars: + if char_list: + print(' '.join((char for char in char_list)), end=' ') + print() + sys.exit(0) diff --git a/poezio/logger.py b/poezio/logger.py new file mode 100644 index 00000000..7efa8f61 --- /dev/null +++ b/poezio/logger.py @@ -0,0 +1,284 @@ +# Copyright 2010-2011 Florent Le Coz <louiz@louiz.org> +# +# This file is part of Poezio. +# +# Poezio is free software: you can redistribute it and/or modify +# it under the terms of the zlib license. See the COPYING file. + +""" +The logger module that handles logging of the poezio +conversations and roster changes +""" + +import mmap +import os +import re +from os import makedirs +from datetime import datetime + +import common +from config import config +from xhtml import clean_text +from theming import dump_tuple, get_theme + +import logging + +log = logging.getLogger(__name__) + +from config import LOG_DIR as log_dir + +message_log_re = re.compile(r'MR (\d{4})(\d{2})(\d{2})T' + r'(\d{2}):(\d{2}):(\d{2})Z ' + r'(\d+) <([^ ]+)> (.*)') +info_log_re = re.compile(r'MI (\d{4})(\d{2})(\d{2})T' + r'(\d{2}):(\d{2}):(\d{2})Z ' + r'(\d+) (.*)') + +def parse_message_line(msg): + if re.match(message_log_re, msg): + return [i for i in re.split(message_log_re, msg) if i] + elif re.match(info_log_re, msg): + return [i for i in re.split(info_log_re, msg) if i] + return False + + +class Logger(object): + """ + Appends things to files. Error/information/warning logs + and also log the conversations to logfiles + """ + def __init__(self): + self.logfile = config.get('logfile') + self.roster_logfile = None + # a dict of 'groupchatname': file-object (opened) + self.fds = dict() + + def __del__(self): + for opened_file in self.fds.values(): + if opened_file: + try: + opened_file.close() + except: # Can't close? too bad + pass + + def reload_all(self): + """Close and reload all the file handles (on SIGHUP)""" + for opened_file in self.fds.values(): + if opened_file: + opened_file.close() + log.debug('All log file handles closed') + for room in self.fds: + self.fds[room] = self.check_and_create_log_dir(room) + log.debug('Log handle for %s re-created', room) + + def check_and_create_log_dir(self, room, open_fd=True): + """ + Check that the directory where we want to log the messages + exists. if not, create it + """ + if not config.get_by_tabname('use_log', room): + return + try: + makedirs(log_dir) + except OSError as e: + if e.errno != 17: # file exists + log.error('Unable to create the log dir', exc_info=True) + except: + log.error('Unable to create the log dir', exc_info=True) + return + if not open_fd: + return + try: + fd = open(os.path.join(log_dir, room), 'a') + self.fds[room] = fd + return fd + except IOError: + log.error('Unable to open the log file (%s)', + os.path.join(log_dir, room), + exc_info=True) + + def get_logs(self, jid, nb=10): + """ + Get the nb last messages from the log history for the given jid. + Note that a message may be more than one line in these files, so + this function is a little bit more complicated than “read the last + nb lines”. + """ + if config.get_by_tabname('load_log', jid) <= 0: + return + + if not config.get_by_tabname('use_log', jid): + return + + if nb <= 0: + return + + self.check_and_create_log_dir(jid, open_fd=False) + + try: + fd = open(os.path.join(log_dir, jid), 'rb') + except FileNotFoundError: + log.info('Non-existing log file (%s)', + os.path.join(log_dir, jid), + exc_info=True) + return + except OSError: + log.error('Unable to open the log file (%s)', + os.path.join(log_dir, jid), + exc_info=True) + return + if not fd: + return + + # read the needed data from the file, we just search nb messages by + # searching "\nM" nb times from the end of the file. We use mmap to + # do that efficiently, instead of seek()s and read()s which are costly. + with fd: + try: + m = mmap.mmap(fd.fileno(), 0, prot=mmap.PROT_READ) + except Exception: # file probably empty + log.error('Unable to mmap the log file for (%s)', + os.path.join(log_dir, jid), + exc_info=True) + return + pos = m.rfind(b"\nM") # start of messages begin with MI or MR, + # after a \n + # number of message found so far + count = 0 + while pos != -1 and count < nb-1: + count += 1 + pos = m.rfind(b"\nM", 0, pos) + if pos == -1: # If we don't have enough lines in the file + pos = 1 # 1, because we do -1 just on the next line + # to get 0 (start of the file) + lines = m[pos-1:].decode(errors='replace').splitlines() + + messages = [] + color = '\x19%s}' % dump_tuple(get_theme().COLOR_LOG_MSG) + + # now convert that data into actual Message objects + idx = 0 + while idx < len(lines): + if lines[idx].startswith(' '): # should not happen ; skip + idx += 1 + log.debug('fail?') + continue + tup = parse_message_line(lines[idx]) + idx += 1 + if not tup or 7 > len(tup) > 10: # skip + log.debug('format? %s', tup) + continue + time = [int(i) for index, i in enumerate(tup) if index < 6] + message = {'lines': [], + 'history': True, + 'time': common.get_local_time(datetime(*time))} + size = int(tup[6]) + if len(tup) == 8: #info line + message['lines'].append(color+tup[7]) + else: # message line + message['nickname'] = tup[7] + message['lines'].append(color+tup[8]) + while size != 0 and idx < len(lines): + message['lines'].append(lines[idx][1:]) + size -= 1 + idx += 1 + message['txt'] = '\n'.join(message['lines']) + del message['lines'] + messages.append(message) + + return messages + + def log_message(self, jid, nick, msg, date=None, typ=1): + """ + log the message in the appropriate jid's file + type: + 0 = Don’t log + 1 = Message + 2 = Status/whatever + """ + if not typ: + return True + + jid = str(jid).replace('/', '\\') + if not config.get_by_tabname('use_log', jid): + return True + if jid in self.fds.keys(): + fd = self.fds[jid] + else: + fd = self.check_and_create_log_dir(jid) + if not fd: + return True + try: + msg = clean_text(msg) + if date is None: + str_time = common.get_utc_time().strftime('%Y%m%dT%H:%M:%SZ') + else: + str_time = common.get_utc_time(date).strftime('%Y%m%dT%H:%M:%SZ') + if typ == 1: + prefix = 'MR' + else: + prefix = 'MI' + lines = msg.split('\n') + first_line = lines.pop(0) + nb_lines = str(len(lines)).zfill(3) + + if nick: + nick = '<' + nick + '>' + fd.write(' '.join((prefix, str_time, nb_lines, nick, ' '+first_line, '\n'))) + else: + fd.write(' '.join((prefix, str_time, nb_lines, first_line, '\n'))) + for line in lines: + fd.write(' %s\n' % line) + except: + log.error('Unable to write in the log file (%s)', + os.path.join(log_dir, jid), + exc_info=True) + return False + else: + try: + fd.flush() # TODO do something better here? + except: + log.error('Unable to flush the log file (%s)', + os.path.join(log_dir, jid), + exc_info=True) + return False + return True + + def log_roster_change(self, jid, message): + """ + Log a roster change + """ + if not config.get_by_tabname('use_log', jid): + return True + self.check_and_create_log_dir('', open_fd=False) + if not self.roster_logfile: + try: + self.roster_logfile = open(os.path.join(log_dir, 'roster.log'), 'a') + except IOError: + log.error('Unable to create the log file (%s)', + os.path.join(log_dir, 'roster.log'), + exc_info=True) + return False + try: + str_time = common.get_utc_time().strftime('%Y%m%dT%H:%M:%SZ') + message = clean_text(message) + lines = message.split('\n') + first_line = lines.pop(0) + nb_lines = str(len(lines)).zfill(3) + self.roster_logfile.write('MI %s %s %s %s\n' % (str_time, nb_lines, jid, first_line)) + for line in lines: + self.roster_logfile.write(' %s\n' % line) + self.roster_logfile.flush() + except: + log.error('Unable to write in the log file (%s)', + os.path.join(log_dir, 'roster.log'), + exc_info=True) + return False + return True + +def create_logger(): + "Create the global logger object" + global logger + logger = Logger() + +logger = None diff --git a/poezio/multiuserchat.py b/poezio/multiuserchat.py new file mode 100644 index 00000000..b7b12305 --- /dev/null +++ b/poezio/multiuserchat.py @@ -0,0 +1,196 @@ +# Copyright 2010-2011 Florent Le Coz <louiz@louiz.org> +# +# This file is part of Poezio. +# +# Poezio is free software: you can redistribute it and/or modify +# it under the terms of the zlib license. See the COPYING file. + +""" +Implementation of the XEP-0045: Multi-User Chat. +Add some facilities that are not available on the XEP_0045 +slix plugin +""" + +from xml.etree import cElementTree as ET + +from common import safeJID +import logging +log = logging.getLogger(__name__) + +NS_MUC_ADMIN = 'http://jabber.org/protocol/muc#admin' +NS_MUC_OWNER = 'http://jabber.org/protocol/muc#owner' + + +def destroy_room(xmpp, room, reason='', altroom=''): + """ + destroy a room + """ + room = safeJID(room) + if not room: + return False + iq = xmpp.make_iq_set() + iq['to'] = room + query = ET.Element('{%s}query' % NS_MUC_OWNER) + destroy = ET.Element('{%s}destroy' % NS_MUC_OWNER) + if altroom: + destroy.attrib['jid'] = altroom + if reason: + xreason = ET.Element('{%s}reason' % NS_MUC_OWNER) + xreason.text = reason + destroy.append(xreason) + query.append(destroy) + iq.append(query) + def callback(iq): + if not iq or iq['type'] == 'error': + xmpp.core.information('Unable to destroy room %s' % room, + 'Info') + else: + xmpp.core.information('Room %s destroyed' % room, 'Info') + iq.send(callback=callback) + return True + +def send_private_message(xmpp, jid, line): + """ + Send a private message + """ + jid = safeJID(jid) + xmpp.send_message(mto=jid, mbody=line, mtype='chat') + +def send_groupchat_message(xmpp, jid, line): + """ + Send a message to the groupchat + """ + jid = safeJID(jid) + xmpp.send_message(mto=jid, mbody=line, mtype='groupchat') + +def change_show(xmpp, jid, own_nick, show, status): + """ + Change our 'Show' + """ + jid = safeJID(jid) + pres = xmpp.make_presence(pto='%s/%s' % (jid, own_nick)) + if show: # if show is None, don't put a <show /> tag. It means "available" + pres['type'] = show + if status: + pres['status'] = status + pres.send() + +def change_subject(xmpp, jid, subject): + """ + Change the room subject + """ + jid = safeJID(jid) + msg = xmpp.make_message(jid) + msg['type'] = 'groupchat' + msg['subject'] = subject + msg.send() + +def change_nick(core, jid, nick, status=None, show=None): + """ + Change our own nick in a room + """ + xmpp = core.xmpp + presence = xmpp.make_presence(pshow=show, pstatus=status, pto=safeJID('%s/%s' % (jid, nick))) + core.events.trigger('changing_nick', presence) + presence.send() + +def join_groupchat(core, jid, nick, passwd='', status=None, show=None, seconds=None): + xmpp = core.xmpp + stanza = xmpp.make_presence(pto='%s/%s' % (jid, nick), pstatus=status, pshow=show) + x = ET.Element('{http://jabber.org/protocol/muc}x') + if passwd: + passelement = ET.Element('password') + passelement.text = passwd + x.append(passelement) + if seconds is not None: + history = ET.Element('{http://jabber.org/protocol/muc}history') + history.attrib['seconds'] = str(seconds) + x.append(history) + stanza.append(x) + core.events.trigger('joining_muc', stanza) + to = stanza["to"] + stanza.send() + xmpp.plugin['xep_0045'].rooms[jid] = {} + xmpp.plugin['xep_0045'].ourNicks[jid] = to.resource + +def leave_groupchat(xmpp, jid, own_nick, msg): + """ + Leave the groupchat + """ + jid = safeJID(jid) + try: + xmpp.plugin['xep_0045'].leaveMUC(jid, own_nick, msg) + except KeyError: + log.debug("muc.leave_groupchat: could not leave the room %s", + jid, exc_info=True) + +def set_user_role(xmpp, jid, nick, reason, role, callback=None): + """ + (try to) Set the role of a MUC user + (role = 'none': eject user) + """ + jid = safeJID(jid) + iq = xmpp.make_iq_set() + query = ET.Element('{%s}query' % NS_MUC_ADMIN) + item = ET.Element('{%s}item' % NS_MUC_ADMIN, {'nick':nick, 'role':role}) + if reason: + reason_el = ET.Element('{%s}reason' % NS_MUC_ADMIN) + reason_el.text = reason + item.append(reason_el) + query.append(item) + iq.append(query) + iq['to'] = jid + if callback: + return iq.send(callback=callback) + try: + return iq.send() + except Exception as e: + return e.iq + +def set_user_affiliation(xmpp, muc_jid, affiliation, nick=None, jid=None, reason=None, callback=None): + """ + (try to) Set the affiliation of a MUC user + """ + muc_jid = safeJID(muc_jid) + query = ET.Element('{http://jabber.org/protocol/muc#admin}query') + if nick: + item = ET.Element('{http://jabber.org/protocol/muc#admin}item', {'affiliation':affiliation, 'nick':nick}) + else: + item = ET.Element('{http://jabber.org/protocol/muc#admin}item', {'affiliation':affiliation, 'jid':str(jid)}) + + if reason: + reason_item = ET.Element('{http://jabber.org/protocol/muc#admin}reason') + reason_item.text = reason + item.append(reason_item) + + query.append(item) + iq = xmpp.make_iq_set(query) + iq['to'] = muc_jid + if callback: + return iq.send(callback=callback) + try: + return xmpp.plugin['xep_0045'].setAffiliation(str(muc_jid), str(jid) if jid else None, nick, affiliation) + except: + import traceback + log.debug('Error setting the affiliation: %s', traceback.format_exc()) + return False + +def cancel_config(xmpp, room): + query = ET.Element('{http://jabber.org/protocol/muc#owner}query') + x = ET.Element('{jabber:x:data}x', type='cancel') + query.append(x) + iq = xmpp.make_iq_set(query) + iq['to'] = room + iq.send() + +def configure_room(xmpp, room, form): + if form is None: + return + iq = xmpp.make_iq_set() + iq['to'] = room + query = ET.Element('{http://jabber.org/protocol/muc#owner}query') + form = form.getXML('submit') + query.append(form) + iq.append(query) + iq.send() + diff --git a/poezio/pep.py b/poezio/pep.py new file mode 100644 index 00000000..0f7a1ced --- /dev/null +++ b/poezio/pep.py @@ -0,0 +1,221 @@ +""" +Collection of mappings for PEP moods/activities +extracted directly from the XEP +""" + +MOODS = { + 'afraid': 'Afraid', + 'amazed': 'Amazed', + 'angry': 'Angry', + 'amorous': 'Amorous', + 'annoyed': 'Annoyed', + 'anxious': 'Anxious', + 'aroused': 'Aroused', + 'ashamed': 'Ashamed', + 'bored': 'Bored', + 'brave': 'Brave', + 'calm': 'Calm', + 'cautious': 'Cautious', + 'cold': 'Cold', + 'confident': 'Confident', + 'confused': 'Confused', + 'contemplative': 'Contemplative', + 'contented': 'Contented', + 'cranky': 'Cranky', + 'crazy': 'Crazy', + 'creative': 'Creative', + 'curious': 'Curious', + 'dejected': 'Dejected', + 'depressed': 'Depressed', + 'disappointed': 'Disappointed', + 'disgusted': 'Disgusted', + 'dismayed': 'Dismayed', + 'distracted': 'Distracted', + 'embarrassed': 'Embarrassed', + 'envious': 'Envious', + 'excited': 'Excited', + 'flirtatious': 'Flirtatious', + 'frustrated': 'Frustrated', + 'grumpy': 'Grumpy', + 'guilty': 'Guilty', + 'happy': 'Happy', + 'hopeful': 'Hopeful', + 'hot': 'Hot', + 'humbled': 'Humbled', + 'humiliated': 'Humiliated', + 'hungry': 'Hungry', + 'hurt': 'Hurt', + 'impressed': 'Impressed', + 'in_awe': 'In awe', + 'in_love': 'In love', + 'indignant': 'Indignant', + 'interested': 'Interested', + 'intoxicated': 'Intoxicated', + 'invincible': 'Invincible', + 'jealous': 'Jealous', + 'lonely': 'Lonely', + 'lucky': 'Lucky', + 'mean': 'Mean', + 'moody': 'Moody', + 'nervous': 'Nervous', + 'neutral': 'Neutral', + 'offended': 'Offended', + 'outraged': 'Outraged', + 'playful': 'Playful', + 'proud': 'Proud', + 'relaxed': 'Relaxed', + 'relieved': 'Relieved', + 'remorseful': 'Remorseful', + 'restless': 'Restless', + 'sad': 'Sad', + 'sarcastic': 'Sarcastic', + 'serious': 'Serious', + 'shocked': 'Shocked', + 'shy': 'Shy', + 'sick': 'Sick', + 'sleepy': 'Sleepy', + 'spontaneous': 'Spontaneous', + 'stressed': 'Stressed', + 'strong': 'Strong', + 'surprised': 'Surprised', + 'thankful': 'Thankful', + 'thirsty': 'Thirsty', + 'tired': 'Tired', + 'undefined': 'Undefined', + 'weak': 'Weak', + 'worried': 'Worried' +} + + + + +ACTIVITIES = { + 'doing_chores': { + 'category': 'Doing_chores', + + 'buying_groceries': 'Buying groceries', + 'cleaning': 'Cleaning', + 'cooking': 'Cooking', + 'doing_maintenance': 'Doing maintenance', + 'doing_the_dishes': 'Doing the dishes', + 'doing_the_laundry': 'Doing the laundry', + 'gardening': 'Gardening', + 'running_an_errand': 'Running an errand', + 'walking_the_dog': 'Walking the dog', + 'other': 'Other', + }, + 'drinking': { + 'category': 'Drinking', + + 'having_a_beer': 'Having a beer', + 'having_coffee': 'Having coffee', + 'having_tea': 'Having tea', + 'other': 'Other', + }, + 'eating': { + 'category':'Eating', + + 'having_breakfast': 'Having breakfast', + 'having_a_snack': 'Having a snack', + 'having_dinner': 'Having dinner', + 'having_lunch': 'Having lunch', + 'other': 'Other', + }, + 'exercising': { + 'category': 'Exercising', + + 'cycling': 'Cycling', + 'dancing': 'Dancing', + 'hiking': 'Hiking', + 'jogging': 'Jogging', + 'playing_sports': 'Playing sports', + 'running': 'Running', + 'skiing': 'Skiing', + 'swimming': 'Swimming', + 'working_out': 'Working out', + 'other': 'Other', + }, + 'grooming': { + 'category': 'Grooming', + + 'at_the_spa': 'At the spa', + 'brushing_teeth': 'Brushing teeth', + 'getting_a_haircut': 'Getting a haircut', + 'shaving': 'Shaving', + 'taking_a_bath': 'Taking a bath', + 'taking_a_shower': 'Taking a shower', + 'other': 'Other', + }, + 'having_appointment': { + 'category': 'Having appointment', + + 'other': 'Other', + }, + 'inactive': { + 'category': 'Inactive', + + 'day_off': 'Day_off', + 'hanging_out': 'Hanging out', + 'hiding': 'Hiding', + 'on_vacation': 'On vacation', + 'praying': 'Praying', + 'scheduled_holiday': 'Scheduled holiday', + 'sleeping': 'Sleeping', + 'thinking': 'Thinking', + 'other': 'Other', + }, + 'relaxing': { + 'category': 'Relaxing', + + 'fishing': 'Fishing', + 'gaming': 'Gaming', + 'going_out': 'Going out', + 'partying': 'Partying', + 'reading': 'Reading', + 'rehearsing': 'Rehearsing', + 'shopping': 'Shopping', + 'smoking': 'Smoking', + 'socializing': 'Socializing', + 'sunbathing': 'Sunbathing', + 'watching_a_movie': 'Watching a movie', + 'watching_tv': 'Watching tv', + 'other': 'Other', + }, + 'talking': { + 'category': 'Talking', + + 'in_real_life': 'In real life', + 'on_the_phone': 'On the phone', + 'on_video_phone': 'On video phone', + 'other': 'Other', + }, + 'traveling': { + 'category': 'Traveling', + + 'commuting': 'Commuting', + 'driving': 'Driving', + 'in_a_car': 'In a car', + 'on_a_bus': 'On a bus', + 'on_a_plane': 'On a plane', + 'on_a_train': 'On a train', + 'on_a_trip': 'On a trip', + 'walking': 'Walking', + 'cycling': 'Cycling', + 'other': 'Other', + }, + 'undefined': { + 'category': 'Undefined', + + 'other': 'Other', + }, + 'working': { + 'category': 'Working', + + 'coding': 'Coding', + 'in_a_meeting': 'In a meeting', + 'writing': 'Writing', + 'studying': 'Studying', + 'other': 'Other', + } + } + diff --git a/poezio/plugin.py b/poezio/plugin.py new file mode 100644 index 00000000..bf30c981 --- /dev/null +++ b/poezio/plugin.py @@ -0,0 +1,485 @@ +""" +Define the PluginConfig and Plugin classes, plus the SafetyMetaclass. +These are used in the plugin system added in poezio 0.7.5 +(see plugin_manager.py) +""" +import os +from functools import partial +from configparser import RawConfigParser +from timed_events import TimedEvent, DelayedEvent +import config +import inspect +import traceback +import logging +log = logging.getLogger(__name__) + +class PluginConfig(config.Config): + """ + Plugin configuration object. + They are accessible inside the plugin with self.config + and behave like the core Config object. + """ + def __init__(self, filename, module_name, default=None): + config.Config.__init__(self, filename, default=default) + self.module_name = module_name + self.read() + + def get(self, option, default=None, section=None): + if not section: + section = self.module_name + return config.Config.get(self, option, default, section) + + def set(self, option, default, section=None): + if not section: + section = self.module_name + return config.Config.set_and_save(self, option, default, section) + + def remove(self, option, section=None): + if not section: + section = self.module_name + return config.Config.remove_and_save(self, option, section) + + def read(self): + """Read the config file""" + RawConfigParser.read(self, self.file_name) + if not self.has_section(self.module_name): + self.add_section(self.module_name) + + def options(self, section=None): + """ + Return the options of the section + If no section is given, it defaults to the plugin name. + """ + if not section: + section = self.module_name + if not self.has_section(section): + self.add_section(section) + return config.Config.options(self, section) + + def write(self): + """Write the config to the disk""" + try: + fp = open(self.file_name, 'w') + RawConfigParser.write(self, fp) + fp.close() + return True + except IOError: + return False + + +class SafetyMetaclass(type): + # A hack + core = None + + @staticmethod + def safe_func(f): + def helper(*args, **kwargs): + try: + return f(*args, **kwargs) + except: + if inspect.stack()[1][1] == inspect.getfile(f): + raise + elif SafetyMetaclass.core: + log.error('Error in a plugin', exc_info=True) + SafetyMetaclass.core.information(traceback.format_exc()) + return None + return helper + + def __new__(meta, name, bases, class_dict): + for k, v in class_dict.items(): + if inspect.isfunction(v): + if k != '__init__' and k != 'init': + class_dict[k] = SafetyMetaclass.safe_func(v) + return type.__new__(meta, name, bases, class_dict) + +class PluginWrap(object): + """ + A wrapper to implicitly pass the module name to PluginAPI + """ + def __init__(self, api, module): + self.api = api + self.module = module + + def __getattribute__(self, name): + api = object.__getattribute__(self, 'api') + module = object.__getattribute__(self, 'module') + return partial(getattr(api, name), module) + +class PluginAPI(object): + """ + The public API exposed to the plugins. + Its goal is to limit the use of the raw Core object + as much as possible. + """ + + def __init__(self, core, plugin_manager): + self.core = core + self.plugin_manager = plugin_manager + + def __getitem__(self, value): + return PluginWrap(self, value) + + def send_message(self, _, *args, **kwargs): + """ + Send a message to the current tab. + + :param str msg: The message to send. + """ + return self.core.send_message(*args, **kwargs) + + def get_conversation_messages(self, _, *args, **kwargs): + """ + Get all the Messages of the current Tab. + + :returns: The list of :py:class:`text_buffer.Message` objects. + :returns: None if the Tab does not inherit from ChatTab. + :rtype: :py:class:`list` + """ + return self.core.get_conversation_messages() + + def add_timed_event(self, _, *args, **kwargs): + """ + Schedule a timed event. + + :param timed_events.TimedEvent event: The timed event to schedule. + """ + return self.core.add_timed_event(*args, **kwargs) + + def remove_timed_event(self, _, *args, **kwargs): + """ + Unschedule a timed event. + + :param timed_events.TimedEvent event: The event to unschedule. + """ + return self.core.remove_timed_event(*args, **kwargs) + + def create_timed_event(self, _, *args, **kwargs): + """ + Create a timed event, but do not schedule it; + :py:func:`~PluginAPI.add_timed_event` must be used for that. + + :param datetime.datetime date: The time at which the handler must be executed + :param function callback: The handler that will be executed + :param \*args: Optional arguments passed to the handler. + :return: The created event. + :rtype: :py:class:`timed_events.TimedEvent` + """ + return TimedEvent(*args, **kwargs) + + def create_delayed_event(self, _, *args, **kwargs): + """ + Create a delayed event, but do not schedule it; + :py:func:`~PluginAPI.add_timed_event` must be used for that. + + A delayed event is a timed event with a delay from the time + this function is called (instead of a datetime). + + :param int delay: The number of seconds to schedule the execution + :param function callback: The handler that will be executed + :param \*args: Optional arguments passed to the handler. + :return: The created event. + :rtype: :py:class:`timed_events.DelayedEvent` + """ + return DelayedEvent(*args, **kwargs) + + def information(self, _, *args, **kwargs): + """ + Display a new message in the information buffer. + + :param str msg: The message to display. + :param str typ: The message type (e.g. Info, Error…) + """ + return self.core.information(*args, **kwargs) + + def current_tab(self, _): + """ + Get the current Tab. + + :returns: The current tab. + """ + return self.core.current_tab() + + def get_status(self, _): + """ + Get the current user global status. + + :returns Status: The current status. + """ + return self.core.get_status() + + def run_command(self, _, *args, **kwargs): + """ + Run a command from the current tab. + (a command starts with a /, if not, it’s a message) + + :param str line: The command to run. + """ + return self.core.current_tab().execute_command(*args, **kwargs) + + def all_tabs(self, _): + """ + Return a list of all opened tabs + + :returns list: The list of tabs. + """ + return self.core.tabs + + def add_command(self, module, *args, **kwargs): + """ + Add a global command. + + :param str name: The name of the command (/name) + :param function handler: The function called when the command is run. + :param str help: The complete help for that command. + :param str short: A short description of the command. + :param function completion: The completion function for that command + (optional) + :param str usage: A string showing the required and optional args + of the command. Optional args should be surrounded by [] + and mandatory args should be surrounded by <>. + + Example string: "<server> [port]" + + :raises Exception: If the command already exists. + """ + return self.plugin_manager.add_command(module, *args, **kwargs) + + def del_command(self, module, *args, **kwargs): + """ + Remove a global command. + + :param str name: The name of the command to remove. + That command _must_ have been added by the same plugin + """ + return self.plugin_manager.del_command(module, *args, **kwargs) + + def add_key(self, module, *args, **kwargs): + """ + Associate a global binding to a handler. + + :param str key: The curses representation of the binding. + :param function handler: The function called when the binding is pressed. + + :raise Exception: If the binding is already present. + """ + return self.plugin_manager.add_key(module, *args, **kwargs) + + def del_key(self, module, *args, **kwargs): + """ + Remove a global binding. + + :param str key: The binding to remove. + """ + return self.plugin_manager.del_key(module, *args, **kwargs) + + def add_tab_key(self, module, *args, **kwargs): + """ + Associate a binding to a handler, but only for a certain tab type. + + :param Tab tab_type: The type of tab to target. + :param str key: The binding to add. + :param function handler: The function called when the binding is pressed + """ + return self.plugin_manager.add_tab_key(module, *args, **kwargs) + + def del_tab_key(self, module, *args, **kwargs): + """ + Remove a binding added with add_tab_key + + :param tabs.Tab tab_type: The type of tab to target. + :param str key: The binding to remove. + """ + return self.plugin_manager.del_tab_key(module, *args, **kwargs) + + def add_tab_command(self, module, *args, **kwargs): + """ + Add a command to only one type of tab. + + :param tabs.Tab tab_type: The type of Tab to target. + :param str name: The name of the command (/name) + :param function handler: The function called when the command is run. + :param str help: The complete help for that command. + :param str short: A short description of the command. + :param function completion: The completion function for that command + (optional) + :param str usage: A string showing the required and optional args + of the command. Optional args should be surrounded by [] + and mandatory args should be surrounded by <>. + + Example string: "<server> [port]" + + :raise Exception: If the command already exists. + """ + return self.plugin_manager.add_tab_command(module, *args, **kwargs) + + def del_tab_command(self, module, *args, **kwargs): + """ + Remove a tab-specific command. + + :param tabs.Tab tab_type: The type of tab to target. + :param str name: The name of the command to remove. + That command _must_ have been added by the same plugin + """ + return self.plugin_manager.del_tab_command(module, *args, **kwargs) + + def add_event_handler(self, module, *args, **kwargs): + """ + Add an event handler for a poezio event. + + :param str event_name: The event name. + :param function handler: The handler function. + :param int position: The position of that handler in the handler list. + This is useful for plugins like GPG or OTR, which must be the last + function called on the text. + Defaults to 0. + + A complete list of those events can be found at + https://doc.poez.io/dev/events.html + """ + return self.plugin_manager.add_event_handler(module, *args, **kwargs) + + def del_event_handler(self, module, *args, **kwargs): + """ + Remove a handler for a poezio event. + + :param str event_name: The name of the targeted event. + :param function handler: The function to remove from the handlers. + """ + return self.plugin_manager.del_event_handler(module, *args, **kwargs) + + def add_slix_event_handler(self, module, event_name, handler): + """ + Add an event handler for a slixmpp event. + + :param str event_name: The event name. + :param function handler: The handler function. + + A list of the slixmpp events can be found here + http://sleekxmpp.com/event_index.html + """ + self.core.xmpp.add_event_handler(event_name, handler) + + def del_slix_event_handler(self, module, event_name, handler): + """ + Remove a handler for a slixmpp event + + :param str event_name: The name of the targeted event. + :param function handler: The function to remove from the handlers. + """ + self.core.xmpp.del_event_handler(event_name, handler) + +class BasePlugin(object, metaclass=SafetyMetaclass): + """ + Class that all plugins derive from. + """ + + default_config = None + + def __init__(self, plugin_api, core, plugins_conf_dir): + self.core = core + # More hack; luckily we'll never have more than one core object + SafetyMetaclass.core = core + conf = os.path.join(plugins_conf_dir, self.__module__+'.cfg') + try: + self.config = PluginConfig(conf, self.__module__, + default=self.default_config) + except Exception: + log.debug('Error while creating the plugin config', exc_info=True) + self.config = PluginConfig(conf, self.__module__) + self._api = plugin_api[self.name] + self.init() + + @property + def name(self): + """ + Get the name (module name) of the plugin. + """ + return self.__module__ + + @property + def api(self): + return self._api + + def init(self): + """ + Method called at the creation of the plugin. + + Do not overwrite __init__ and use this instead. + """ + pass + + def cleanup(self): + """ + Called when the plugin is unloaded. + + Overwrite this if you want to erase or save things before the plugin is disabled. + """ + pass + + def unload(self): + self.cleanup() + + def add_command(self, name, handler, help, completion=None, short='', usage=''): + """ + Add a global command. + You cannot overwrite the existing commands. + """ + return self.api.add_command(name, handler, help, + completion=completion, short=short, usage=usage) + + def del_command(self, name): + """ + Remove a global command. + This only works if the command was added by the plugin + """ + return self.api.del_command(name) + + def add_key(self, key, handler): + """ + Add a global keybind + """ + return self.api.add_key(key, handler) + + def del_key(self, key): + """ + Remove a global keybind + """ + return self.api.del_key(key) + + def add_tab_key(self, tab_type, key, handler): + """ + Add a keybind only for a type of tab. + """ + return self.api.add_tab_key(tab_type, key, handler) + + def del_tab_key(self, tab_type, key): + """ + Remove a keybind added through add_tab_key. + """ + return self.api.del_tab_key(tab_type, key) + + def add_tab_command(self, tab_type, name, handler, help, completion=None, short='', usage=''): + """ + Add a command only for a type of tab. + """ + return self.api.add_tab_command(tab_type, name, handler, help, + completion=completion, short=short, usage=usage) + + def del_tab_command(self, tab_type, name): + """ + Delete a command added through add_tab_command. + """ + return self.api.del_tab_command(tab_type, name) + + def add_event_handler(self, event_name, handler, position=0): + """ + Add an event handler to the event event_name. + An optional position in the event handler list can be provided. + """ + return self.api.add_event_handler(event_name, handler, position) + + def del_event_handler(self, event_name, handler): + """ + Remove 'handler' from the event list for 'event_name'. + """ + return self.api.del_event_handler(event_name, handler) diff --git a/poezio/plugin_manager.py b/poezio/plugin_manager.py new file mode 100644 index 00000000..549753a9 --- /dev/null +++ b/poezio/plugin_manager.py @@ -0,0 +1,384 @@ +""" +Plugin manager module. +Define the PluginManager class, the one that glues all the plugins and +the API together. Defines also a bunch of variables related to the +plugin env. +""" + +import os +from os import path +import logging + +import core +import tabs +from plugin import PluginAPI +from config import config + +log = logging.getLogger(__name__) + +class PluginManager(object): + """ + Plugin Manager + Contains all the references to the plugins + And keeps track of everything the plugin has done through the API. + """ + def __init__(self, core): + self.core = core + # module name -> module object + self.modules = {} + # module name -> plugin object + self.plugins = {} + # module name -> dict of commands loaded for the module + self.commands = {} + # module name -> list of event_name/handler pairs loaded for the module + self.event_handlers = {} + # module name -> dict of tab types; tab type -> commands + # loaded by the module + self.tab_commands = {} + # module name → dict of keys/handlers loaded for the module + self.keys = {} + # module name → dict of tab types; tab type → list of keybinds (tuples) + self.tab_keys = {} + self.roster_elements = {} + + from importlib import machinery + self.finder = machinery.PathFinder() + + self.initial_set_plugins_dir() + self.initial_set_plugins_conf_dir() + self.fill_load_path() + + self.plugin_api = PluginAPI(core, self) + + def disable_plugins(self): + for plugin in set(self.plugins.keys()): + try: + self.unload(plugin, notify=False) + except: + pass + + def load(self, name, notify=True): + """ + Load a plugin. + """ + if name in self.plugins: + self.unload(name) + + try: + module = None + loader = self.finder.find_module(name, self.load_path) + if not loader: + self.core.information('Could not find plugin: %s' % name) + return + module = loader.load_module() + except Exception as e: + log.debug("Could not load plugin %s", name, exc_info=True) + self.core.information("Could not load plugin %s: %s" % (name, e), + 'Error') + finally: + if not module: + return + + self.modules[name] = module + self.commands[name] = {} + self.keys[name] = {} + self.tab_keys[name] = {} + self.tab_commands[name] = {} + self.event_handlers[name] = [] + try: + self.plugins[name] = None + self.plugins[name] = module.Plugin(self.plugin_api, self.core, + self.plugins_conf_dir) + except Exception as e: + log.error('Error while loading the plugin %s', name, exc_info=True) + if notify: + self.core.information('Unable to load the plugin %s: %s' % + (name, e), + 'Error') + self.unload(name, notify=False) + else: + if notify: + self.core.information('Plugin %s loaded' % name, 'Info') + + def unload(self, name, notify=True): + if name in self.plugins: + try: + for command in self.commands[name].keys(): + del self.core.commands[command] + for key in self.keys[name].keys(): + del self.core.key_func[key] + for tab in list(self.tab_commands[name].keys()): + for command in self.tab_commands[name][tab][:]: + self.del_tab_command(name, getattr(tabs, tab), + command[0]) + del self.tab_commands[name][tab] + for tab in list(self.tab_keys[name].keys()): + for key in self.tab_keys[name][tab][:]: + self.del_tab_key(name, getattr(tabs, tab), key[0]) + del self.tab_keys[name][tab] + for event_name, handler in self.event_handlers[name][:]: + self.del_event_handler(name, event_name, handler) + + if self.plugins[name] is not None: + self.plugins[name].unload() + del self.plugins[name] + del self.commands[name] + del self.keys[name] + del self.tab_commands[name] + del self.event_handlers[name] + if notify: + self.core.information('Plugin %s unloaded' % name, 'Info') + except Exception as e: + log.debug("Could not unload plugin %s", name, exc_info=True) + self.core.information("Could not unload plugin %s: %s" % + (name, e), + 'Error') + + def add_command(self, module_name, name, handler, help, + completion=None, short='', usage=''): + """ + Add a global command. + """ + if name in self.core.commands: + raise Exception("Command '%s' already exists" % (name,)) + + commands = self.commands[module_name] + commands[name] = core.Command(handler, help, completion, short, usage) + self.core.commands[name] = commands[name] + + def del_command(self, module_name, name): + """ + Remove a global command added through add_command. + """ + if name in self.commands[module_name]: + del self.commands[module_name][name] + if name in self.core.commands: + del self.core.commands[name] + + def add_tab_command(self, module_name, tab_type, name, handler, help, + completion=None, short='', usage=''): + """ + Add a command only for a type of Tab. + """ + commands = self.tab_commands[module_name] + t = tab_type.__name__ + if name in tab_type.plugin_commands: + return + if not t in commands: + commands[t] = [] + commands[t].append((name, handler, help, completion)) + tab_type.plugin_commands[name] = core.Command(handler, help, + completion, short, usage) + for tab in self.core.tabs: + if isinstance(tab, tab_type): + tab.update_commands() + + def del_tab_command(self, module_name, tab_type, name): + """ + Remove a command added through add_tab_command. + """ + commands = self.tab_commands[module_name] + t = tab_type.__name__ + if not t in commands: + return + for command in commands[t]: + if command[0] == name: + commands[t].remove(command) + del tab_type.plugin_commands[name] + for tab in self.core.tabs: + if isinstance(tab, tab_type) and name in tab.commands: + del tab.commands[name] + + def add_tab_key(self, module_name, tab_type, key, handler): + """ + Associate a key binding to a handler only for a type of Tab. + """ + keys = self.tab_keys[module_name] + t = tab_type.__name__ + if key in tab_type.plugin_keys: + return + if not t in keys: + keys[t] = [] + keys[t].append((key, handler)) + tab_type.plugin_keys[key] = handler + for tab in self.core.tabs: + if isinstance(tab, tab_type): + tab.update_keys() + + def del_tab_key(self, module_name, tab_type, key): + """ + Remove a key binding added through add_tab_key. + """ + keys = self.tab_keys[module_name] + t = tab_type.__name__ + if not t in keys: + return + for _key in keys[t]: + if _key[0] == key: + keys[t].remove(_key) + del tab_type.plugin_keys[key] + for tab in self.core.tabs: + if isinstance(tab, tab_type) and key in tab.key_func: + del tab.key_func[key] + + def add_key(self, module_name, key, handler): + """ + Associate a global key binding to a handler, except if it + already exists. + """ + if key in self.core.key_func: + raise Exception("Key '%s' already exists" % (key,)) + keys = self.keys[module_name] + keys[key] = handler + self.core.key_func[key] = handler + + def del_key(self, module_name, key): + """ + Remove a global key binding added by a plugin. + """ + if key in self.keys[module_name]: + del self.keys[module_name][key] + if key in self.core.key_func: + del self.core.commands[key] + + def add_event_handler(self, module_name, event_name, handler, position=0): + """ + Add an event handler. If event_name isn’t in the event list, assume + it is a slixmpp event. + """ + eh = self.event_handlers[module_name] + eh.append((event_name, handler)) + if event_name in self.core.events.events: + self.core.events.add_event_handler(event_name, handler, position) + else: + self.core.xmpp.add_event_handler(event_name, handler) + + def del_event_handler(self, module_name, event_name, handler): + """ + Remove an event handler if it exists. + """ + if event_name in self.core.events.events: + self.core.events.del_event_handler(None, handler) + else: + self.core.xmpp.del_event_handler(event_name, handler) + eh = self.event_handlers[module_name] + eh = list(filter(lambda e: e != (event_name, handler), eh)) + + def completion_load(self, the_input): + """ + completion function that completes the name of the plugins, from + all .py files in plugins_dir + """ + try: + names = set() + for path in self.load_path: + try: + add = set(os.listdir(path)) + names |= add + except: + pass + except OSError as e: + self.core.information('Completion failed: %s' % e, 'Error') + return + plugins_files = [name[:-3] for name in names if name.endswith('.py') + and name != '__init__.py' and not name.startswith('.')] + plugins_files.sort() + position = the_input.get_argument_position(quoted=False) + return the_input.new_completion(plugins_files, position, '', + quotify=False) + + def completion_unload(self, the_input): + """ + completion function that completes the name of loaded plugins + """ + position = the_input.get_argument_position(quoted=False) + return the_input.new_completion(sorted(self.plugins.keys()), position, + '', quotify=False) + + def on_plugins_dir_change(self, new_value): + self.plugins_dir = new_value + self.check_create_plugins_dir() + self.fill_load_path() + + def on_plugins_conf_dir_change(self, new_value): + self.plugins_conf_dir = new_value + self.check_create_plugins_conf_dir() + + def initial_set_plugins_conf_dir(self): + """ + Create the plugins_conf_dir + """ + plugins_conf_dir = config.get('plugins_conf_dir') + if not plugins_conf_dir: + config_home = os.environ.get('XDG_CONFIG_HOME') + if not config_home: + config_home = os.path.join(os.environ.get('HOME'), '.config') + plugins_conf_dir = os.path.join(config_home, 'poezio', 'plugins') + self.plugins_conf_dir = os.path.expanduser(plugins_conf_dir) + self.check_create_plugins_conf_dir() + + def check_create_plugins_conf_dir(self): + """ + Create the plugins config directory if it does not exist. + Returns True on success, False on failure. + """ + if not os.access(self.plugins_conf_dir, os.R_OK | os.X_OK): + try: + os.makedirs(self.plugins_conf_dir) + except OSError: + log.error('Unable to create the plugin conf dir: %s', + self.plugins_conf_dir, exc_info=True) + return False + return True + + def initial_set_plugins_dir(self): + """ + Set the plugins_dir on start + """ + plugins_dir = config.get('plugins_dir') + plugins_dir = plugins_dir or\ + os.path.join(os.environ.get('XDG_DATA_HOME') or\ + os.path.join(os.environ.get('HOME'), + '.local', 'share'), + 'poezio', 'plugins') + self.plugins_dir = os.path.expanduser(plugins_dir) + self.check_create_plugins_dir() + + def check_create_plugins_dir(self): + """ + Create the plugins directory if it does not exist. + Returns True on success, False on failure. + """ + if not os.access(self.plugins_dir, os.R_OK | os.X_OK): + try: + os.makedirs(self.plugins_dir, exist_ok=True) + except OSError: + log.error('Unable to create the plugins dir: %s', + self.plugins_dir, exc_info=True) + return False + return True + + def fill_load_path(self): + """ + Append the global packages and the source directory if available + """ + + self.load_path = [] + + default_plugin_path = path.join(path.dirname(path.dirname(__file__)), + 'plugins') + + if os.access(default_plugin_path, os.R_OK | os.X_OK): + self.load_path.insert(0, default_plugin_path) + + if os.access(self.plugins_dir, os.R_OK | os.X_OK): + self.load_path.append(self.plugins_dir) + + try: + import poezio_plugins + except: + pass + else: + if poezio_plugins.__path__: + self.load_path.append(list(poezio_plugins.__path__)[0]) + diff --git a/poezio/poezio.py b/poezio/poezio.py new file mode 100644 index 00000000..9fb6fb73 --- /dev/null +++ b/poezio/poezio.py @@ -0,0 +1,115 @@ +# Copyright 2010-2011 Florent Le Coz <louiz@louiz.org> +# +# This file is part of Poezio. +# +# Poezio is free software: you can redistribute it and/or modify +# it under the terms of the zlib license. See the COPYING file. + + +""" +Starting point of poezio. Launches both the Connection and Gui +""" + +import sys +import os +import signal +import logging + +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +import singleton + +def test_curses(): + """ + Check if the system ncurses linked with python has unicode capabilities. + """ + import curses + if hasattr(curses, 'unget_wch'): + return True + print("""\ +ERROR: The current python executable is linked with a ncurses version that \ +has no unicode capabilities. + +This could mean that: + - python was built on a system where readline is linked against \ +libncurses and not libncursesw + - python was built without ncursesw headers available + +Please file a bug for your distribution or fix that on your system and then \ +recompile python. +Poezio is currently unable to read your input or draw its interface properly,\ + so it will now exit.""") + return False + + +def main(): + """ + Enter point + """ + sys.stdout.write("\x1b]0;poezio\x07") + sys.stdout.flush() + import config + config_path = config.check_create_config_dir() + config.run_cmdline_args(config_path) + config.create_global_config() + config.check_create_log_dir() + config.check_create_cache_dir() + config.setup_logging() + config.post_logging_setup() + + from config import options + + if options.check_config: + config.check_config() + sys.exit(0) + + import theming + theming.update_themes_dir() + + import logger + logger.create_logger() + + import roster + roster.create_roster() + + import core + + log = logging.getLogger('') + + signal.signal(signal.SIGINT, signal.SIG_IGN) # ignore ctrl-c + cocore = singleton.Singleton(core.Core) + signal.signal(signal.SIGUSR1, cocore.sigusr_handler) # reload the config + signal.signal(signal.SIGHUP, cocore.exit_from_signal) + signal.signal(signal.SIGTERM, cocore.exit_from_signal) + if options.debug: + cocore.debug = True + cocore.start() + + from slixmpp.exceptions import IqError, IqTimeout + def swallow_iqerrors(loop, context): + """Do not log unhandled iq errors and timeouts""" + if not isinstance(context['exception'], (IqError, IqTimeout)): + loop.default_exception_handler(context) + + # Warning: asyncio must always be imported after the config. Otherwise + # the asyncio logger will not follow our configuration and won't write + # the tracebacks in the correct file, etc + import asyncio + loop = asyncio.get_event_loop() + loop.set_exception_handler(swallow_iqerrors) + + loop.add_reader(sys.stdin, cocore.on_input_readable) + loop.add_signal_handler(signal.SIGWINCH, cocore.sigwinch_handler) + cocore.xmpp.start() + loop.run_forever() + # We reach this point only when loop.stop() is called + try: + cocore.reset_curses() + except: + pass + +if __name__ == '__main__': + if test_curses(): + main() + else: + sys.exit(1) diff --git a/poezio/poezio_shlex.py b/poezio/poezio_shlex.py new file mode 100644 index 00000000..032baeee --- /dev/null +++ b/poezio/poezio_shlex.py @@ -0,0 +1,276 @@ +""" +A lexical analyzer class for simple shell-like syntaxes. + +Tweaked for the specific needs of parsing poezio input. + +""" + +# Module and documentation by Eric S. Raymond, 21 Dec 1998 +# Input stacking and error message cleanup added by ESR, March 2000 +# push_source() and pop_source() made explicit by ESR, January 2001. +# Posix compliance, split(), string arguments, and +# iterator interface by Gustavo Niemeyer, April 2003. + +import os +import re +import sys +from collections import deque + +from io import StringIO + +__all__ = ["shlex", "split", "quote"] + +class shlex(object): + """ + A custom version of the shlex in the stdlib to yield more information + """ + def __init__(self, instream=None, infile=None, posix=True): + if isinstance(instream, str): + instream = StringIO(instream) + if instream is not None: + self.instream = instream + self.infile = infile + else: + self.instream = sys.stdin + self.infile = None + self.posix = posix + self.eof = '' + self.commenters = '' + self.wordchars = ('abcdfeghijklmnopqrstuvwxyz' + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_') + if self.posix: + self.wordchars += ('ßàáâãäåæçèéêëìíîïðñòóôõöøùúûüýþÿ' + 'ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞ') + self.whitespace = ' \t\r\n' + self.whitespace_split = True + self.quotes = '"' + self.escape = '\\' + self.escapedquotes = '"' + self.state = ' ' + self.pushback = deque() + self.lineno = 1 + self.debug = 0 + self.token = '' + self.filestack = deque() + self.source = None + if self.debug: + print('shlex: reading from %s, line %d' \ + % (self.instream, self.lineno)) + + def push_token(self, tok): + "Push a token onto the stack popped by the get_token method" + if self.debug >= 1: + print("shlex: pushing token " + repr(tok)) + self.pushback.appendleft(tok) + + def push_source(self, newstream, newfile=None): + "Push an input source onto the lexer's input source stack." + if isinstance(newstream, str): + newstream = StringIO(newstream) + self.filestack.appendleft((self.infile, self.instream, self.lineno)) + self.infile = newfile + self.instream = newstream + self.lineno = 1 + if self.debug: + if newfile is not None: + print('shlex: pushing to file %s' % (self.infile,)) + else: + print('shlex: pushing to stream %s' % (self.instream,)) + + def pop_source(self): + "Pop the input source stack." + self.instream.close() + (self.infile, self.instream, self.lineno) = self.filestack.popleft() + if self.debug: + print('shlex: popping to %s, line %d' \ + % (self.instream, self.lineno)) + self.state = ' ' + + def get_token(self): + "Get a token from the input stream (or from stack if it's nonempty)" + if self.pushback: + tok = self.pushback.popleft() + if self.debug >= 1: + print("shlex: popping token " + repr(tok)) + return tok + # No pushback. Get a token. + start, end, raw = self.read_token() + return start, end, raw + + def read_token(self): + quoted = False + escapedstate = ' ' + token_start = 0 + token_end = -1 + # read one char from the stream at once + while True: + nextchar = self.instream.read(1) + if nextchar == '\n': + self.lineno = self.lineno + 1 + if self.debug >= 3: + print("shlex: in state", repr(self.state), \ + "I see character:", repr(nextchar)) + if self.state is None: + self.token = '' # past end of file + token_end = self.instream.tell() + break + elif self.state == ' ': + if not nextchar: + self.state = None # end of file + token_end = self.instream.tell() + break + elif nextchar in self.whitespace: + if self.debug >= 2: + print("shlex: I see whitespace in whitespace state") + if self.token or (self.posix and quoted): + token_end = self.instream.tell() - 1 + break # emit current token + else: + continue + elif nextchar in self.wordchars: + token_start = self.instream.tell() - 1 + self.token = nextchar + self.state = 'a' + elif nextchar in self.quotes: + token_start = self.instream.tell() - 1 + self.state = nextchar + elif self.whitespace_split: + token_start = self.instream.tell() - 1 + self.token = nextchar + self.state = 'a' + else: + token_start = self.instream.tell() - 1 + self.token = nextchar + if self.token or (self.posix and quoted): + token_end = self.instream.tell() - 1 + break # emit current token + else: + continue + elif self.state in self.quotes: + quoted = True + if not nextchar: # end of file + if self.debug >= 2: + print("shlex: I see EOF in quotes state") + # XXX what error should be raised here? + token_end = self.instream.tell() + break + if nextchar == self.state: + if not self.posix: + self.token = self.token + nextchar + self.state = ' ' + token_end = self.instream.tell() + break + else: + self.state = 'a' + elif self.posix and nextchar in self.escape and \ + self.state in self.escapedquotes: + escapedstate = self.state + self.state = nextchar + else: + self.token = self.token + nextchar + elif self.state in self.escape: + if not nextchar: # end of file + if self.debug >= 2: + print("shlex: I see EOF in escape state") + # XXX what error should be raised here? + token_end = self.instream.tell() + break + # only the quote may be escaped + if escapedstate in self.quotes and nextchar != escapedstate: + self.token = self.token + self.state + self.token = self.token + nextchar + self.state = escapedstate + elif self.state == 'a': + if not nextchar: + self.state = None # end of file + token_end = self.instream.tell() + break + elif nextchar in self.whitespace: + if self.debug >= 2: + print("shlex: I see whitespace in word state") + self.state = ' ' + if self.token or (self.posix and quoted): + token_end = self.instream.tell() - 1 + break # emit current token + else: + continue + elif nextchar in self.wordchars or nextchar in self.quotes \ + or self.whitespace_split: + self.token = self.token + nextchar + else: + self.pushback.appendleft(nextchar) + if self.debug >= 2: + print("shlex: I see punctuation in word state") + self.state = ' ' + if self.token: + token_end = self.instream.tell() + break # emit current token + else: + continue + result = self.token + self.token = '' + if self.posix and not quoted and result == '': + result = None + if self.debug > 1: + if result: + print("shlex: raw token=" + repr(result)) + else: + print("shlex: raw token=EOF") + return (token_start, token_end, result) + + def sourcehook(self, newfile): + "Hook called on a filename to be sourced." + if newfile[0] == '"': + newfile = newfile[1:-1] + # This implements cpp-like semantics for relative-path inclusion. + if isinstance(self.infile, str) and not os.path.isabs(newfile): + newfile = os.path.join(os.path.dirname(self.infile), newfile) + return (newfile, open(newfile, "r")) + + def error_leader(self, infile=None, lineno=None): + "Emit a C-compiler-like, Emacs-friendly error-message leader." + if infile is None: + infile = self.infile + if lineno is None: + lineno = self.lineno + return "\"%s\", line %d: " % (infile, lineno) + + def __iter__(self): + return self + + def __next__(self): + token = self.get_token() + if token and token[0] == self.eof: + raise StopIteration + return token + +def split(s, comments=False, posix=True): + lex = shlex(s, posix=posix) + lex.whitespace_split = True + if not comments: + lex.commenters = '' + return list(lex) + + +_find_unsafe = re.compile(r'[^\w@%+=:,./-]', re.ASCII).search + +def quote(s): + """Return a shell-escaped version of the string *s*.""" + if not s: + return "''" + if _find_unsafe(s) is None: + return s + + # use single quotes, and put single quotes into double quotes + # the string $'b is then quoted as '$'"'"'b' + return "'" + s.replace("'", "'\"'\"'") + "'" + + +if __name__ == '__main__': + lexer = shlex(instream=sys.argv[1]) + while 1: + tt = lexer.get_token() + if tt: + print("Token: " + repr(tt)) + else: + break diff --git a/poezio/pooptmodule.c b/poezio/pooptmodule.c new file mode 100644 index 00000000..69fb7f6f --- /dev/null +++ b/poezio/pooptmodule.c @@ -0,0 +1,486 @@ +/* Copyright 2010-2011 Florent Le Coz <louiz@louiz.org> */ + +/* This file is part of Poezio. */ + +/* Poezio is free software: you can redistribute it and/or modify */ +/* it under the terms of the zlib license. See the COPYING file. */ + +/** The poopt python3 module +**/ + +/* This file is a python3 module for poezio, used to replace some time-critical +python functions that are too slow. */ + +#define PY_SSIZE_T_CLEAN + +#include "Python.h" + +PyObject *ErrorObject; + +/*** + Internal functions + ***/ + +/** + Just checking if the return value is -1. In some (all?) implementations, + wcwidth("😆") returns -1 while it should return 1. In these cases, we + return 1 instead because this is by far the most probable real value. + Since the string is received from python, and the unicode character is + extracted with mbrtowc(), and supposing these two compononents are not + bugged, and since poezio’s code should never pass '\t', '\n' or their + friends, a return value of -1 from wcwidth() is considered to be a bug in + wcwidth() (until proven otherwise). xwcwidth() is here to work around + this bug. */ +static int xwcwidth(wchar_t c) +{ + const int res = wcwidth(c); + if (res == -1 && c != '\x19') + return 1; + return res; +} + +/*** + The module functions + ***/ + +/** + cut_text: takes a string and returns a tuple of int. + + Each two int tuple is a line, represented by the ending position it + (where it should be cut). Not that this position is calculed using the + position of the python string characters, not just the individual bytes. + + For example, + poopt_cut_text("vivent les réfrigérateurs", 6); + will return [(0, 6), (7, 10), (11, 17), (17, 22), (22, 24)], meaning that + the lines are + "vivent", "les", "réfrig", "érateu" and "rs" + +*/ +PyDoc_STRVAR(poopt_cut_text_doc, "cut_text(text, width)\n\n\nReturn a list of two-tuple, the first int is the starting position of the line and the second is its end."); + +static PyObject* poopt_cut_text(PyObject* self, PyObject* args) +{ + /* The list of tuples that we return */ + PyObject* retlist = PyList_New(0); + /* The temporary name for the tuples */ + PyObject* tmp; + + /* Get the python arguments */ + const size_t width; + const char* buffer; + const Py_ssize_t buffer_len; + + if (PyArg_ParseTuple(args, "s#k", &buffer, &buffer_len, &width) == 0) + return NULL; + + /* Pointer to the end of the string */ + const char* const end = buffer + buffer_len; + + /* The position, considering unicode chars (aka, the position in the + * python string). This is used to determine the position in the python + * string at which we should cut */ + unsigned int spos = 0; + + /* The start position (in the python-string) of the next line */ + unsigned int start_pos = 0; + + /* The position of the last space seen in the current line. This is used + * to cut on spaces instead of cutting inside words, if possible (aka if + * there is a space) */ + int last_space = -1; + /* The number of columns taken by chars between start_pos and last_space */ + size_t cols_until_space = 0; + + /* The number of bytes consumed by mbrtowc. We advance the buffer ptr by this value */ + size_t consumed; + + /* Number of columns taken to display the current line so far */ + size_t columns = 0; + + /* The unicode character found by mbrtowc */ + wchar_t wc; + + while (buffer < end) + { + /* Special case to jump poezio special characters that are contained + * in the python string, but should not be counted as chars, because + * they will not be displayed. Those are the formatting chars (to + * insert colors or things like that in the string) */ + if (*buffer == 25) /* \x19 */ + { + /* Jump everything until the end of this format marker, but + * without increasing the number of columns of the current + * line. Because these chars are not printed. */ + while (buffer < end && *buffer != 'u' && + *buffer != 'a' && *buffer != 'i' && + *buffer != 'b' && *buffer != 'o' && + *buffer != '}') + { + buffer++; + spos++; + } + buffer++; + spos++; + continue; + } + /* Find the next unicode character (a wchar_t) in the string. This + * may consume from one to 4 bytes. */ + consumed = mbrtowc(&wc, buffer, end-buffer, NULL); + if (consumed == 0) + break ; + else if ((size_t)-1 == consumed) + { + PyErr_SetString(PyExc_UnicodeError, + "mbrtowc returned -1: Invalid multibyte sequence."); + return NULL; + } + else if ((size_t)-2 == consumed) + { + PyErr_SetString(PyExc_UnicodeError, + "mbrtowc returned -2: Could not parse a complete multibyte character."); + return NULL; + } + + buffer += consumed; + + /* This is one condition to end the line: an explicit \n is found */ + if (wc == (wchar_t)'\n') + { + spos++; + tmp = Py_BuildValue("II", start_pos, spos); + if (PyList_Append(retlist, tmp) == -1) + { + Py_XDECREF(tmp); + return NULL; + } + Py_XDECREF(tmp); + /* And then initiate a new line */ + start_pos = spos; + last_space = -1; + columns = 0; + continue ; + } + + /* Get the number of columns needed to display this character. May be 0, 1 or 2 */ + const size_t cols = xwcwidth(wc); + + /* This is the second condition to end the line: we have consumed + * enough columns to fill a whole line */ + if (columns + cols > width) + { /* If possible, cut on a space */ + if (last_space != -1) + { + tmp = Py_BuildValue("II", start_pos, last_space); + if (PyList_Append(retlist, tmp) == -1) + { + Py_XDECREF(tmp); + return NULL; + } + Py_XDECREF(tmp); + start_pos = last_space + 1; + last_space = -1; + columns -= (cols_until_space + 1); + } + else + { + /* Otherwise, cut in the middle of a word */ + tmp = Py_BuildValue("II", start_pos, spos); + if (PyList_Append(retlist, tmp) == -1) + { + Py_XDECREF(tmp); + return NULL; + } + Py_XDECREF(tmp); + start_pos = spos; + columns = 0; + } + } + /* We save the position of the last space seen in this line, and the + number of columns we have until now. This helps us keep track of + the columns to count when we will use that space as a cutting + point, later */ + if (wc == (wchar_t)' ') + { + last_space = spos; + cols_until_space = columns; + } + /* We advanced from one char, increment spos by one and add the + * char's columns to the line's columns */ + columns += cols; + spos++; + } + /* We are at the end of the string, append the last line, not finished */ + tmp = Py_BuildValue("II", start_pos, spos); + if (PyList_Append(retlist, tmp) == -1) + { + Py_XDECREF(tmp); + return NULL; + } + Py_XDECREF(tmp); + return retlist; +} + +/** + wcswidth: An emulation of the POSIX wcswidth(3) function using wcwidth + and mbrtowc. +*/ +PyDoc_STRVAR(poopt_wcswidth_doc, "wcswidth(s)\n\n\nThe wcswidth() function returns the number of columns needed to represent the wide-character string pointed to by s. Raise UnicodeError if an invalid unicode value is passed"); +static PyObject* poopt_wcswidth(PyObject* self, PyObject* args) +{ + const char* string; + const Py_ssize_t len; + if (PyArg_ParseTuple(args, "s#", &string, &len) == 0) + return NULL; + const char* const end = string + len; + wchar_t wc; + int res = 0; + while (string < end) + { + const size_t consumed = mbrtowc(&wc, string, end-string, NULL); + if (consumed == 0) + break ; + else if ((size_t)-1 == consumed) + { + PyErr_SetString(PyExc_UnicodeError, + "mbrtowc returned -1: Invalid multibyte sequence."); + return NULL; + } + else if ((size_t)-2 == consumed) + { + PyErr_SetString(PyExc_UnicodeError, + "mbrtowc returned -2: Could not parse a complete multibyte character."); + return NULL; + } + string += consumed; + res += xwcwidth(wc); + } + return Py_BuildValue("i", res); +} + +/** + cut_by_columns: takes a python string and a number of columns, returns a + python string truncated to take at most that many columns + For example cut_by_columns(n, "エメルカ") will return: + - n == 5 -> "エメ" (which takes only 4 columns since we can't cut the + next character in half) + - n == 2 -> "エ" + - n == 1 -> "" + - n == 42 -> "エメルカ" + - etc +*/ +PyDoc_STRVAR(poopt_cut_by_columns_doc, "cut_by_columns(n, string)\n\n\nreturns a string truncated to take at most n columns"); +static PyObject* poopt_cut_by_columns(PyObject* self, PyObject* args) +{ + const char* start; + const Py_ssize_t len; + const size_t limit; + if (PyArg_ParseTuple(args, "s#k", &start, &len, &limit) == 0) + return NULL; + + const char* const end = start + len; + const char* ptr = start; + wchar_t wc; + + /* The number of columns that the string would take so far */ + size_t columns = 0; + + while (ptr < end) + { + if (columns == limit) + break ; + const size_t consumed = mbrtowc(&wc, ptr, end-ptr, NULL); + if (consumed == 0) + break ; + else if ((size_t)-1 == consumed) + { + PyErr_SetString(PyExc_UnicodeError, + "mbrtowc returned -1: Invalid multibyte sequence."); + return NULL; + } + else if ((size_t)-2 == consumed) + { + PyErr_SetString(PyExc_UnicodeError, + "mbrtowc returned -2: Could not parse a complete multibyte character."); + return NULL; + } + const size_t cols = wcwidth(wc); + if (columns + cols > limit) + /* Adding the next character would exceed the column limit */ + break ; + ptr += consumed; + columns += cols; + } + return Py_BuildValue("s#", start, ptr - start); +} + +/*** + Module initialization. Just taken from the xxmodule.c template from the + python sources. + ***/ +static PyTypeObject Str_Type = { + PyVarObject_HEAD_INIT(NULL, 0) + "pooptmodule.Str", /*tp_name*/ + 0, /*tp_basicsize*/ + 0, /*tp_itemsize*/ + /* methods */ + 0, /*tp_dealloc*/ + 0, /*tp_print*/ + 0, /*tp_getattr*/ + 0, /*tp_setattr*/ + 0, /*tp_reserved*/ + 0, /*tp_repr*/ + 0, /*tp_as_number*/ + 0, /*tp_as_sequence*/ + 0, /*tp_as_mapping*/ + 0, /*tp_hash*/ + 0, /*tp_call*/ + 0, /*tp_str*/ + 0, /*tp_getattro*/ + 0, /*tp_setattro*/ + 0, /*tp_as_buffer*/ + Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, /*tp_flags*/ + 0, /*tp_doc*/ + 0, /*tp_traverse*/ + 0, /*tp_clear*/ + 0, /*tp_richcompare*/ + 0, /*tp_weaklistoffset*/ + 0, /*tp_iter*/ + 0, /*tp_iternext*/ + 0, /*tp_methods*/ + 0, /*tp_members*/ + 0, /*tp_getset*/ + 0, /* see PyInit_xx */ /*tp_base*/ + 0, /*tp_dict*/ + 0, /*tp_descr_get*/ + 0, /*tp_descr_set*/ + 0, /*tp_dictoffset*/ + 0, /*tp_init*/ + 0, /*tp_alloc*/ + 0, /*tp_new*/ + 0, /*tp_free*/ + 0, /*tp_is_gc*/ +}; + +static PyObject * +null_richcompare(PyObject *self, PyObject *other, int op) +{ + Py_INCREF(Py_NotImplemented); + return Py_NotImplemented; +} + +static PyTypeObject Null_Type = { + PyVarObject_HEAD_INIT(NULL, 0) + "pooptmodule.Null", /*tp_name*/ + 0, /*tp_basicsize*/ + 0, /*tp_itemsize*/ + /* methods */ + 0, /*tp_dealloc*/ + 0, /*tp_print*/ + 0, /*tp_getattr*/ + 0, /*tp_setattr*/ + 0, /*tp_reserved*/ + 0, /*tp_repr*/ + 0, /*tp_as_number*/ + 0, /*tp_as_sequence*/ + 0, /*tp_as_mapping*/ + 0, /*tp_hash*/ + 0, /*tp_call*/ + 0, /*tp_str*/ + 0, /*tp_getattro*/ + 0, /*tp_setattro*/ + 0, /*tp_as_buffer*/ + Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, /*tp_flags*/ + 0, /*tp_doc*/ + 0, /*tp_traverse*/ + 0, /*tp_clear*/ + null_richcompare, /*tp_richcompare*/ + 0, /*tp_weaklistoffset*/ + 0, /*tp_iter*/ + 0, /*tp_iternext*/ + 0, /*tp_methods*/ + 0, /*tp_members*/ + 0, /*tp_getset*/ + 0, /* see PyInit_xx */ /*tp_base*/ + 0, /*tp_dict*/ + 0, /*tp_descr_get*/ + 0, /*tp_descr_set*/ + 0, /*tp_dictoffset*/ + 0, /*tp_init*/ + 0, /*tp_alloc*/ + 0, /* see PyInit_xx */ /*tp_new*/ + 0, /*tp_free*/ + 0, /*tp_is_gc*/ +}; + + +/* List of functions defined in the module */ +static PyMethodDef poopt_methods[] = { + {"cut_text", poopt_cut_text, METH_VARARGS, poopt_cut_text_doc}, + {"wcswidth", poopt_wcswidth, METH_VARARGS, poopt_wcswidth_doc}, + {"cut_by_columns", poopt_cut_by_columns, METH_VARARGS, poopt_cut_by_columns_doc}, + {} /* sentinel */ +}; + +PyDoc_STRVAR(module_doc, + "This is a template module just for instruction. And poopt."); + +/* Initialization function for the module (*must* be called PyInit_xx) */ + +static struct PyModuleDef pooptmodule = { + PyModuleDef_HEAD_INIT, + "poopt", + module_doc, + -1, + poopt_methods, + NULL, + NULL, + NULL, + NULL +}; + +PyMODINIT_FUNC +PyInit_poopt(void) +{ + PyObject *m = NULL; + + /* Due to cross platform compiler issues the slots must be filled + * here. It's required for portability to Windows without requiring + * C++. */ + Null_Type.tp_base = &PyBaseObject_Type; + Null_Type.tp_new = PyType_GenericNew; + Str_Type.tp_base = &PyUnicode_Type; + + /* Finalize the type object including setting type of the new type + * object; doing it here is required for portability, too. */ + /* if (PyType_Ready(&Xxo_Type) < 0) */ + /* goto fail; */ + + /* Create the module and add the functions */ + m = PyModule_Create(&pooptmodule); + if (m == NULL) + goto fail; + + /* Add some symbolic constants to the module */ + if (ErrorObject == NULL) { + ErrorObject = PyErr_NewException("poopt.error", NULL, NULL); + if (ErrorObject == NULL) + goto fail; + } + Py_INCREF(ErrorObject); + PyModule_AddObject(m, "error", ErrorObject); + + /* Add Str */ + if (PyType_Ready(&Str_Type) < 0) + goto fail; + PyModule_AddObject(m, "Str", (PyObject *)&Str_Type); + + /* Add Null */ + if (PyType_Ready(&Null_Type) < 0) + goto fail; + PyModule_AddObject(m, "Null", (PyObject *)&Null_Type); + return m; + fail: + Py_XDECREF(m); + return NULL; +} diff --git a/poezio/roster.py b/poezio/roster.py new file mode 100644 index 00000000..ba7da63e --- /dev/null +++ b/poezio/roster.py @@ -0,0 +1,334 @@ +# Copyright 2010-2011 Florent Le Coz <louiz@louiz.org> +# +# This file is part of Poezio. +# +# Poezio is free software: you can redistribute it and/or modify +# it under the terms of the zlib license. See the COPYING file. + + +""" +Defines the Roster and RosterGroup classes +""" +import logging +log = logging.getLogger(__name__) + +from config import config +from contact import Contact +from roster_sorting import SORTING_METHODS, GROUP_SORTING_METHODS + +from os import path as p +from datetime import datetime +from common import safeJID +from slixmpp.exceptions import IqError, IqTimeout + + +class Roster(object): + """ + The proxy class to get the roster from slixmpp. + Caches Contact and RosterGroup objects. + """ + + def __init__(self): + """ + node: the RosterSingle from slixmpp + """ + self.__node = None + self.contact_filter = None # A tuple(function, *args) + # function to filter contacts, + # on search, for example + self.folded_groups = set(config.get('folded_roster_groups', + section='var').split(':')) + self.groups = {} + self.contacts = {} + self.length = 0 + self.connected = 0 + + # Used for caching roster infos + self.last_built = datetime.now() + self.last_modified = datetime.now() + + def modified(self): + self.last_modified = datetime.now() + + @property + def needs_rebuild(self): + return self.last_modified >= self.last_built + + def __getitem__(self, key): + """Get a Contact from his bare JID""" + key = safeJID(key).bare + if key in self.contacts and self.contacts[key] is not None: + return self.contacts[key] + if key in self.jids(): + contact = Contact(self.__node[key]) + self.contacts[key] = contact + return contact + + def __setitem__(self, key, value): + """Set the a Contact value for the bare jid key""" + self.contacts[key] = value + + def remove(self, jid): + """Send a removal iq to the server""" + jid = safeJID(jid).bare + if self.__node[jid]: + try: + self.__node[jid].send_presence(ptype='unavailable') + self.__node.remove(jid) + except (IqError, IqTimeout): + log.debug('IqError when removing %s:', jid, exc_info=True) + + def __delitem__(self, jid): + """Remove a contact from the roster view""" + jid = safeJID(jid).bare + contact = self[jid] + if not contact: + return + del self.contacts[contact.bare_jid] + + for group in list(self.groups.values()): + group.remove(contact) + if not group: + del self.groups[group.name] + self.modified() + + def __iter__(self): + """Iterate over the jids of the contacts""" + return iter(self.contacts.values()) + + def __contains__(self, key): + """True if the bare jid is in the roster, false otherwise""" + return safeJID(key).bare in self.jids() + + @property + def jid(self): + """Our JID""" + return self.__node.jid + + def get_and_set(self, jid): + contact = self.contacts.get(jid) + if contact is None: + contact = Contact(self.__node[jid]) + self.contacts[jid] = contact + return contact + return contact + + def set_node(self, value): + """Set the slixmpp RosterSingle for our roster""" + self.__node = value + + def get_groups(self, sort=''): + """Return a list of the RosterGroups""" + group_list = sorted( + (group for group in self.groups.values() if group), + key=lambda x: x.name.lower() if x.name else '' + ) + + for sorting in sort.split(':'): + if sorting == 'reverse': + group_list = list(reversed(group_list)) + else: + method = GROUP_SORTING_METHODS.get(sorting, lambda x: 0) + group_list = sorted(group_list, key=method) + return group_list + + def get_group(self, name): + """Return a group or create it if not present""" + if name in self.groups: + return self.groups[name] + self.groups[name] = RosterGroup(name, folded=name in self.folded_groups) + + def add(self, jid): + """Subscribe to a jid""" + self.__node.subscribe(jid) + + def jids(self): + """List of the contact JIDS""" + l = [] + for key in self.__node.keys(): + contact = self.get_and_set(key) + if key != self.jid and (contact and self.exists(contact)): + l.append(key) + self.update_size(l) + return l + + def update_size(self, jids=None): + if jids is None: + jids = self.jids() + self.length = len(jids) + + def get_contacts(self): + """ + Return a list of all the contacts + """ + return [self[jid] for jid in self.jids()] + + def get_contacts_sorted_filtered(self, sort=''): + """ + Return a list of all the contacts sorted with a criteria + """ + contact_list = [] + for contact in self.get_contacts(): + if contact.bare_jid != self.jid: + if self.contact_filter: + if self.contact_filter[0](contact, self.contact_filter[1]): + contact_list.append(contact) + else: + contact_list.append(contact) + contact_list = sorted(contact_list, key=SORTING_METHODS['name']) + + for sorting in sort.split(':'): + if sorting == 'reverse': + contact_list = list(reversed(contact_list)) + else: + method = SORTING_METHODS.get(sorting, lambda x: 0) + contact_list = sorted(contact_list, key=method) + return contact_list + + def save_to_config_file(self): + """ + Save various information to the config file + e.g. the folded groups + """ + folded_groups = ':'.join([group.name for group in self.groups.values()\ + if group.folded]) + log.debug('folded:%s\n', folded_groups) + return config.silent_set('folded_roster_groups', folded_groups, 'var') + + def get_nb_connected_contacts(self): + """ + Get the number of connected contacts + """ + return self.connected + + def update_contact_groups(self, contact): + """Regenerate the RosterGroups when receiving a contact update""" + if not isinstance(contact, Contact): + contact = self.get_and_set(contact) + if not contact: + return + for name, group in self.groups.items(): + if name in contact.groups and contact not in group: + group.add(contact) + elif contact in group and name not in contact.groups: + group.remove(contact) + + for group in contact.groups: + if not group in self.groups: + self.groups[group] = RosterGroup(group, folded=group in self.folded_groups) + self.groups[group].add(contact) + + def __len__(self): + """ + Return the number of contacts + (used to return the display size, but now we have + the display cache in RosterWin for that) + """ + return self.length + + def __repr__(self): + ret = '== Roster:\nContacts:\n' + for contact in self.contacts.values(): + ret += '%s\n' % (contact,) + ret += 'Groups\n' + for group in self.groups: + ret += '%s\n' % (group,) + return ret + '\n' + + def export(self, path): + """Export a list of bare jids to a given file""" + if p.isfile(path): + return + try: + f = open(path, 'w+', encoding='utf-8') + f.writelines([str(i) + "\n" for i in self.contacts if self[i] and (self[i].subscription == "both" or self[i].ask)]) + f.close() + return True + except IOError: + return + except OSError: + return + + def exists(self, contact): + if not contact: + return False + for group in contact.groups: + if contact not in self.groups.get(group, tuple()): + return False + return True + + +class RosterGroup(object): + """ + A RosterGroup is a group containing contacts + It can be Friends/Family etc, but also can be + Online/Offline or whatever + """ + def __init__(self, name, contacts=None, folded=False): + if not contacts: + contacts = [] + self.contacts = set(contacts) + self.name = name if name is not None else '' + self.folded = folded # if the group content is to be shown + + def __iter__(self): + """Iterate over the contacts""" + return iter(self.contacts) + + def __repr__(self): + return '<Roster_group: %s; %s>' % (self.name, self.contacts) + + def __len__(self): + """Number of contacts in the group""" + return len(self.contacts) + + def __contains__(self, contact): + """ + Return a bool, telling if the contact is in the group + """ + return contact in self.contacts + + def add(self, contact): + """Add a contact to the group""" + self.contacts.add(contact) + + def remove(self, contact): + """Remove a contact from the group if present""" + if contact in self.contacts: + self.contacts.remove(contact) + + def get_contacts(self, contact_filter=None, sort=''): + """Return the group contacts, filtered and sorted""" + contact_list = self.contacts.copy() if not contact_filter\ + else [contact for contact in self.contacts.copy() if contact_filter[0](contact, contact_filter[1])] + contact_list = sorted(contact_list, key=SORTING_METHODS['name']) + + for sorting in sort.split(':'): + if sorting == 'reverse': + contact_list = list(reversed(contact_list)) + else: + method = SORTING_METHODS.get(sorting, lambda x: 0) + contact_list = sorted(contact_list, key=method) + return contact_list + + def toggle_folded(self): + """Fold/unfold the group in the roster""" + self.folded = not self.folded + if self.folded: + if self.name not in roster.folded_groups: + roster.folded_groups.add(self.name) + else: + if self.name in roster.folded_groups: + roster.folded_groups.remove(self.name) + + def get_nb_connected_contacts(self): + """Return the number of connected contacts""" + return len([1 for contact in self.contacts if len(contact)]) + +def create_roster(): + "Create the global roster object" + global roster + roster = Roster() + +# Shared roster object +roster = None diff --git a/poezio/roster_sorting.py b/poezio/roster_sorting.py new file mode 100644 index 00000000..c57f0dce --- /dev/null +++ b/poezio/roster_sorting.py @@ -0,0 +1,90 @@ +""" +Defines the roster sorting methods used in roster.py +(for contacts/groups) +""" + +########################### Contacts sorting ############################ + +PRESENCE_PRIORITY = {'unavailable': 5, + 'xa': 4, + 'away': 3, + 'dnd': 2, + '': 1, + 'available': 1} + +def sort_jid(contact): + """Sort by contact JID""" + return contact.bare_jid + +def sort_show(contact): + """Sort by show (from high availability to low)""" + res = contact.get_highest_priority_resource() + if not res: + return 5 + show = res.presence + if show not in PRESENCE_PRIORITY: + return 0 + return PRESENCE_PRIORITY[show] + +def sort_resource_nb(contact): + """Sort by number of connected resources""" + return - len(contact) + +def sort_name(contact): + """Sort by name (case insensitive)""" + return contact.name.lower() or contact.bare_jid + +def sort_sname(contact): + """Sort by name (case sensitive)""" + return contact.name or contact.bare_jid + +def sort_online(contact): + """Sort by connected/disconnected""" + result = sort_show(contact) + return 0 if result < 5 else 1 + +SORTING_METHODS = { + 'jid': sort_jid, + 'sname': sort_sname, + 'show': sort_show, + 'resource': sort_resource_nb, + 'name': sort_name, + 'online': sort_online, +} + + +######################## Roster Groups sorting ########################## + +def sort_group_name(group): + """Sort by name (case insensitive)""" + return group.name.lower() + +def sort_group_sname(group): + """Sort by name (case-sensitive)""" + return group.name + +def sort_group_folded(group): + """Sort by folded/unfolded""" + return group.folded + +def sort_group_connected(group): + """Sort by number of connected contacts""" + return - group.get_nb_connected_contacts() + +def sort_group_size(group): + """Sort by group size""" + return - len(group) + +def sort_group_none(group): + """Put the none group at the end, if any""" + return 0 if group.name != 'none' else 1 + +GROUP_SORTING_METHODS = { + 'name': sort_group_name, + 'fold': sort_group_folded, + 'connected': sort_group_connected, + 'size': sort_group_size, + 'none': sort_group_none, + 'sname': sort_group_sname, +} + diff --git a/poezio/singleton.py b/poezio/singleton.py new file mode 100644 index 00000000..9133012b --- /dev/null +++ b/poezio/singleton.py @@ -0,0 +1,20 @@ +# Copyright 2010-2011 Florent Le Coz <louiz@louiz.org> +# +# This file is part of Poezio. +# +# Poezio is free software: you can redistribute it and/or modify +# it under the terms of the zlib license. See the COPYING file. + +""" +Defines a Singleton function that initialize an object +of the given class if it was never instantiated yet. Else, returns +the previously instantiated object. +This method is the only one that I can come up with that do not call +__init__() each time. +""" + +instances = {} +def Singleton(cls, *args, **kwargs): + if not cls in instances: + instances[cls] = cls(*args, **kwargs) + return instances[cls] diff --git a/poezio/size_manager.py b/poezio/size_manager.py new file mode 100644 index 00000000..1cad83fd --- /dev/null +++ b/poezio/size_manager.py @@ -0,0 +1,46 @@ +""" +Size Manager: + used to check size boundaries of the whole window and + specific tabs +""" +THRESHOLD_WIDTH_DEGRADE = 45 +THRESHOLD_HEIGHT_DEGRADE = 10 + +FULL_WIDTH_DEGRADE = 66 +FULL_HEIGHT_DEGRADE = 10 + +class SizeManager(object): + + def __init__(self, core, win_cls): + self._win_class = win_cls + self._core = core + + @property + def tab_scr(self): + return self._win_class._tab_win + + @property + def core_scr(self): + return self._core.stdscr + + @property + def tab_degrade_x(self): + _, x = self.tab_scr.getmaxyx() + return x < THRESHOLD_WIDTH_DEGRADE + + @property + def tab_degrade_y(self): + y, x = self.tab_scr.getmaxyx() + return y < THRESHOLD_HEIGHT_DEGRADE + + @property + def core_degrade_x(self): + y, x = self.core_scr.getmaxyx() + return x < FULL_WIDTH_DEGRADE + + @property + def core_degrade_y(self): + y, x = self.core_scr.getmaxyx() + return y < FULL_HEIGHT_DEGRADE + + diff --git a/poezio/tabs/__init__.py b/poezio/tabs/__init__.py new file mode 100644 index 00000000..d0a881a6 --- /dev/null +++ b/poezio/tabs/__init__.py @@ -0,0 +1,13 @@ +from . basetabs import Tab, ChatTab, GapTab, OneToOneTab +from . basetabs import STATE_PRIORITY +from . rostertab import RosterInfoTab +from . muctab import MucTab, NS_MUC_USER +from . privatetab import PrivateTab +from . conversationtab import ConversationTab, StaticConversationTab,\ + DynamicConversationTab +from . xmltab import XMLTab +from . listtab import ListTab +from . muclisttab import MucListTab +from . adhoc_commands_list import AdhocCommandsListTab +from . data_forms import DataFormsTab +from . bookmarkstab import BookmarksTab diff --git a/poezio/tabs/adhoc_commands_list.py b/poezio/tabs/adhoc_commands_list.py new file mode 100644 index 00000000..10ebf22b --- /dev/null +++ b/poezio/tabs/adhoc_commands_list.py @@ -0,0 +1,57 @@ +""" +A tab listing the ad-hoc commands on a specific JID. The user can +select one of them and start executing it, or just close the tab and do +nothing. +""" + +import logging +log = logging.getLogger(__name__) + +from . import ListTab + +from slixmpp.plugins.xep_0030.stanza.items import DiscoItem + +class AdhocCommandsListTab(ListTab): + plugin_commands = {} + plugin_keys = {} + + def __init__(self, jid): + ListTab.__init__(self, jid.full, + "“Enter”: execute selected command.", + 'Ad-hoc commands of JID %s (Loading)' % jid, + (('Node', 0), ('Description', 1))) + self.key_func['^M'] = self.execute_selected_command + + def execute_selected_command(self): + if not self.listview or not self.listview.get_selected_row(): + return + node, name, jid = self.listview.get_selected_row() + session = {'next': self.core.on_next_adhoc_step, + 'error': self.core.on_adhoc_error} + self.core.xmpp.plugin['xep_0050'].start_command(jid, node, session) + + def get_columns_sizes(self): + return {'Node': int(self.width * 3 / 8), + 'Description': int(self.width * 5 / 8)} + + def on_list_received(self, iq): + """ + Fill the listview with the value from the received iq + """ + if iq['type'] == 'error': + self.set_error(iq['error']['type'], iq['error']['code'], iq['error']['text']) + return + def get_items(): + substanza = iq['disco_items'] + for item in substanza['substanzas']: + if isinstance(item, DiscoItem): + yield item + items = [(item['node'], item['name'] or '', item['jid']) for item in get_items()] + self.listview.set_lines(items) + self.info_header.message = 'Ad-hoc commands of JID %s' % self.name + if self.core.current_tab() is self: + self.refresh() + else: + self.state = 'highlight' + self.refresh_tab_win() + self.core.doupdate() diff --git a/poezio/tabs/basetabs.py b/poezio/tabs/basetabs.py new file mode 100644 index 00000000..bb0c0ea4 --- /dev/null +++ b/poezio/tabs/basetabs.py @@ -0,0 +1,881 @@ +""" +Module for the base Tabs + +The root class Tab defines the generic interface and attributes of a +tab. A tab organizes various Windows around the screen depending +of the tab specificity. If the tab shows messages, it will also +reference a buffer containing the messages. + +Each subclass should redefine its own refresh() and resize() method +according to its windows. + +This module also defines ChatTabs, the parent class for all tabs +revolving around chats. +""" + +import logging +log = logging.getLogger(__name__) + +import singleton +import string +import time +import weakref +from datetime import datetime, timedelta +from xml.etree import cElementTree as ET + +import core +import timed_events +import windows +import xhtml +from common import safeJID +from config import config +from decorators import refresh_wrapper +from logger import logger +from text_buffer import TextBuffer +from theming import get_theme, dump_tuple +from decorators import command_args_parser + +# getters for tab colors (lambdas, so that they are dynamic) +STATE_COLORS = { + 'disconnected': lambda: get_theme().COLOR_TAB_DISCONNECTED, + 'scrolled': lambda: get_theme().COLOR_TAB_SCROLLED, + 'nonempty': lambda: get_theme().COLOR_TAB_NONEMPTY, + 'joined': lambda: get_theme().COLOR_TAB_JOINED, + 'message': lambda: get_theme().COLOR_TAB_NEW_MESSAGE, + 'composing': lambda: get_theme().COLOR_TAB_COMPOSING, + 'highlight': lambda: get_theme().COLOR_TAB_HIGHLIGHT, + 'private': lambda: get_theme().COLOR_TAB_PRIVATE, + 'normal': lambda: get_theme().COLOR_TAB_NORMAL, + 'current': lambda: get_theme().COLOR_TAB_CURRENT, + 'attention': lambda: get_theme().COLOR_TAB_ATTENTION, + } +VERTICAL_STATE_COLORS = { + 'disconnected': lambda: get_theme().COLOR_VERTICAL_TAB_DISCONNECTED, + 'scrolled': lambda: get_theme().COLOR_VERTICAL_TAB_SCROLLED, + 'nonempty': lambda: get_theme().COLOR_VERTICAL_TAB_NONEMPTY, + 'joined': lambda: get_theme().COLOR_VERTICAL_TAB_JOINED, + 'message': lambda: get_theme().COLOR_VERTICAL_TAB_NEW_MESSAGE, + 'composing': lambda: get_theme().COLOR_VERTICAL_TAB_COMPOSING, + 'highlight': lambda: get_theme().COLOR_VERTICAL_TAB_HIGHLIGHT, + 'private': lambda: get_theme().COLOR_VERTICAL_TAB_PRIVATE, + 'normal': lambda: get_theme().COLOR_VERTICAL_TAB_NORMAL, + 'current': lambda: get_theme().COLOR_VERTICAL_TAB_CURRENT, + 'attention': lambda: get_theme().COLOR_VERTICAL_TAB_ATTENTION, + } + + +# priority of the different tab states when using Alt+e +# higher means more priority, < 0 means not selectable +STATE_PRIORITY = { + 'normal': -1, + 'current': -1, + 'disconnected': 0, + 'nonempty': 0.1, + 'scrolled': 0.5, + 'joined': 0.8, + 'composing': 0.9, + 'message': 1, + 'highlight': 2, + 'private': 2, + 'attention': 3 + } + +class Tab(object): + tab_core = None + size_manager = None + + plugin_commands = {} + plugin_keys = {} + def __init__(self): + if not hasattr(self, 'name'): + self.name = self.__class__.__name__ + self.input = None + self.closed = False + self._state = 'normal' + self._prev_state = None + + self.need_resize = False + self.key_func = {} # each tab should add their keys in there + # and use them in on_input + self.commands = {} # and their own commands + + + @property + def size(self): + if not Tab.size_manager: + Tab.size_manager = self.core.size + return Tab.size_manager + + @property + def core(self): + if not Tab.tab_core: + Tab.tab_core = singleton.Singleton(core.Core) + return Tab.tab_core + + @property + def nb(self): + for index, tab in enumerate(self.core.tabs): + if tab == self: + return index + return len(self.core.tabs) + + @property + def tab_win(self): + if not Tab.tab_core: + Tab.tab_core = singleton.Singleton(core.Core) + return Tab.tab_core.tab_win + + @property + def left_tab_win(self): + if not Tab.tab_core: + Tab.tab_core = singleton.Singleton(core.Core) + return Tab.tab_core.left_tab_win + + @staticmethod + def tab_win_height(): + """ + Returns 1 or 0, depending on if we are using the vertical tab list + or not. + """ + if config.get('enable_vertical_tab_list'): + return 0 + return 1 + + @property + def info_win(self): + return self.core.information_win + + @property + def color(self): + return STATE_COLORS[self._state]() + + @property + def vertical_color(self): + return VERTICAL_STATE_COLORS[self._state]() + + @property + def state(self): + return self._state + + @state.setter + def state(self, value): + if not value in STATE_COLORS: + log.debug("Invalid value for tab state: %s", value) + elif STATE_PRIORITY[value] < STATE_PRIORITY[self._state] and \ + value not in ('current', 'disconnected') and \ + not (self._state == 'scrolled' and value == 'disconnected'): + log.debug("Did not set state because of lower priority, asked: %s, kept: %s", value, self._state) + elif self._state == 'disconnected' and value not in ('joined', 'current'): + log.debug('Did not set state because disconnected tabs remain visible') + else: + self._state = value + if self._state == 'current': + self._prev_state = None + + def set_state(self, value): + self._state = value + + def save_state(self): + if self._state != 'composing': + self._prev_state = self._state + + def restore_state(self): + if self.state == 'composing' and self._prev_state: + self._state = self._prev_state + self._prev_state = None + elif not self._prev_state: + self._state = 'normal' + + @staticmethod + def resize(scr): + Tab.height, Tab.width = scr.getmaxyx() + windows.Win._tab_win = scr + + def missing_command_callback(self, command_name): + """ + Callback executed when a command is not found. + Returns True if the callback took care of displaying + the error message, False otherwise. + """ + return False + + def register_command(self, name, func, *, desc='', shortdesc='', completion=None, usage=''): + """ + Add a command + """ + if name in self.commands: + return + if not desc and shortdesc: + desc = shortdesc + self.commands[name] = core.Command(func, desc, completion, shortdesc, usage) + + def complete_commands(self, the_input): + """ + Does command completion on the specified input for both global and tab-specific + commands. + This should be called from the completion method (on tab, for example), passing + the input where completion is to be made. + It can completion the command name itself or an argument of the command. + Returns True if a completion was made, False else. + """ + txt = the_input.get_text() + # check if this is a command + if txt.startswith('/') and not txt.startswith('//'): + position = the_input.get_argument_position(quoted=False) + if position == 0: + words = ['/%s'% (name) for name in sorted(self.core.commands)] +\ + ['/%s' % (name) for name in sorted(self.commands)] + the_input.new_completion(words, 0) + # Do not try to cycle command completion if there was only + # one possibily. The next tab will complete the argument. + # Otherwise we would need to add a useless space before being + # able to complete the arguments. + hit_copy = set(the_input.hit_list) + while not hit_copy: + whitespace = the_input.text.find(' ') + if whitespace == -1: + whitespace = len(the_input.text) + the_input.text = the_input.text[:whitespace-1] + the_input.text[whitespace:] + the_input.new_completion(words, 0) + hit_copy = set(the_input.hit_list) + if len(hit_copy) == 1: + the_input.do_command(' ') + the_input.reset_completion() + return True + # check if we are in the middle of the command name + elif len(txt.split()) > 1 or\ + (txt.endswith(' ') and not the_input.last_completion): + command_name = txt.split()[0][1:] + if command_name in self.commands: + command = self.commands[command_name] + elif command_name in self.core.commands: + command = self.core.commands[command_name] + else: # Unknown command, cannot complete + return False + if command[2] is None: + return False # There's no completion function + else: + return command[2](the_input) + return False + + def execute_command(self, provided_text): + """ + Execute the command in the input and return False if + the input didn't contain a command + """ + txt = provided_text or self.input.key_enter() + if txt.startswith('/') and not txt.startswith('//') and\ + not txt.startswith('/me '): + command = txt.strip().split()[0][1:] + arg = txt[2+len(command):] # jump the '/' and the ' ' + func = None + if command in self.commands: # check tab-specific commands + func = self.commands[command][0] + elif command in self.core.commands: # check global commands + func = self.core.commands[command][0] + else: + low = command.lower() + if low in self.commands: + func = self.commands[low][0] + elif low in self.core.commands: + func = self.core.commands[low][0] + else: + if self.missing_command_callback is not None: + error_handled = self.missing_command_callback(low) + if not error_handled: + self.core.information("Unknown command (%s)" % + (command), + 'Error') + if command in ('correct', 'say'): # hack + arg = xhtml.convert_simple_to_full_colors(arg) + else: + arg = xhtml.clean_text_simple(arg) + if func: + if hasattr(self.input, "reset_completion"): + self.input.reset_completion() + func(arg) + return True + else: + return False + + def refresh_tab_win(self): + if config.get('enable_vertical_tab_list'): + if self.left_tab_win and not self.size.core_degrade_x: + self.left_tab_win.refresh() + elif not self.size.core_degrade_y: + self.tab_win.refresh() + + def refresh(self): + """ + Called on each screen refresh (when something has changed) + """ + pass + + def get_name(self): + """ + get the name of the tab + """ + return self.name + + def get_nick(self): + """ + Get the nick of the tab (defaults to its name) + """ + return self.name + + def get_text_window(self): + """ + Returns the principal TextWin window, if there's one + """ + return None + + def on_input(self, key, raw): + """ + raw indicates if the key should activate the associated command or not. + """ + pass + + def update_commands(self): + for c in self.plugin_commands: + if not c in self.commands: + self.commands[c] = self.plugin_commands[c] + + def update_keys(self): + for k in self.plugin_keys: + if not k in self.key_func: + self.key_func[k] = self.plugin_keys[k] + + def on_lose_focus(self): + """ + called when this tab loses the focus. + """ + self.state = 'normal' + + def on_gain_focus(self): + """ + called when this tab gains the focus. + """ + self.state = 'current' + + def on_scroll_down(self): + """ + Defines what happens when we scroll down + """ + pass + + def on_scroll_up(self): + """ + Defines what happens when we scroll up + """ + pass + + def on_line_up(self): + """ + Defines what happens when we scroll one line up + """ + pass + + def on_line_down(self): + """ + Defines what happens when we scroll one line up + """ + pass + + def on_half_scroll_down(self): + """ + Defines what happens when we scroll half a screen down + """ + pass + + def on_half_scroll_up(self): + """ + Defines what happens when we scroll half a screen up + """ + pass + + def on_info_win_size_changed(self): + """ + Called when the window with the informations is resized + """ + pass + + def on_close(self): + """ + Called when the tab is to be closed + """ + if self.input: + self.input.on_delete() + self.closed = True + + def matching_names(self): + """ + Returns a list of strings that are used to name a tab with the /win + command. For example you could switch to a tab that returns + ['hello', 'coucou'] using /win hel, or /win coucou + If not implemented in the tab, it just doesn’t match with anything. + """ + return [] + + def __del__(self): + log.debug('------ Closing tab %s', self.__class__.__name__) + +class GapTab(Tab): + + def __bool__(self): + return False + + def __len__(self): + return 0 + + @property + def name(self): + return '' + + def refresh(self): + log.debug('WARNING: refresh() called on a gap tab, this should not happen') + +class ChatTab(Tab): + """ + A tab containing a chat of any type. + Just use this class instead of Tab if the tab needs a recent-words completion + Also, ^M is already bound to on_enter + And also, add the /say command + """ + plugin_commands = {} + plugin_keys = {} + def __init__(self, jid=''): + Tab.__init__(self) + self.name = jid + self.text_win = None + self._text_buffer = TextBuffer() + self.chatstate = None # can be "active", "composing", "paused", "gone", "inactive" + # We keep a reference of the event that will set our chatstate to "paused", so that + # we can delete it or change it if we need to + self.timed_event_paused = None + # Keeps the last sent message to complete it easily in completion_correct, and to replace it. + self.last_sent_message = None + self.key_func['M-v'] = self.move_separator + self.key_func['M-h'] = self.scroll_separator + self.key_func['M-/'] = self.last_words_completion + self.key_func['^M'] = self.on_enter + self.register_command('say', self.command_say, + usage='<message>', + shortdesc='Send the message.') + self.register_command('xhtml', self.command_xhtml, + usage='<custom xhtml>', + shortdesc='Send custom XHTML.') + self.register_command('clear', self.command_clear, + shortdesc='Clear the current buffer.') + self.register_command('correct', self.command_correct, + desc='Fix the last message with whatever you want.', + shortdesc='Correct the last message.', + completion=self.completion_correct) + self.chat_state = None + self.update_commands() + self.update_keys() + + # Get the logs + log_nb = config.get('load_log') + logs = self.load_logs(log_nb) + + if logs: + for message in logs: + self._text_buffer.add_message(**message) + + @property + def is_muc(self): + return False + + def load_logs(self, log_nb): + logs = logger.get_logs(safeJID(self.name).bare, log_nb) + return logs + + def log_message(self, txt, nickname, time=None, typ=1): + """ + Log the messages in the archives. + """ + name = safeJID(self.name).bare + if not logger.log_message(name, nickname, txt, date=time, typ=typ): + self.core.information('Unable to write in the log file', 'Error') + + def add_message(self, txt, time=None, nickname=None, forced_user=None, + nick_color=None, identifier=None, jid=None, history=None, + typ=1, highlight=False): + self.log_message(txt, nickname, time=time, typ=typ) + self._text_buffer.add_message(txt, time=time, + nickname=nickname, + highlight=highlight, + nick_color=nick_color, + history=history, + user=forced_user, + identifier=identifier, + jid=jid) + + def modify_message(self, txt, old_id, new_id, user=None, jid=None, nickname=None): + self.log_message(txt, nickname, typ=1) + message = self._text_buffer.modify_message(txt, old_id, new_id, time=time, user=user, jid=jid) + if message: + self.text_win.modify_message(old_id, message) + self.core.refresh_window() + return True + return False + + def last_words_completion(self): + """ + Complete the input with words recently said + """ + # build the list of the recent words + char_we_dont_want = string.punctuation+' ’„“”…«»' + words = list() + for msg in self._text_buffer.messages[:-40:-1]: + if not msg: + continue + txt = xhtml.clean_text(msg.txt) + for char in char_we_dont_want: + txt = txt.replace(char, ' ') + for word in txt.split(): + if len(word) >= 4 and word not in words: + words.append(word) + words.extend([word for word in config.get('words').split(':') if word]) + self.input.auto_completion(words, ' ', quotify=False) + + def on_enter(self): + txt = self.input.key_enter() + if txt: + if not self.execute_command(txt): + if txt.startswith('//'): + txt = txt[1:] + self.command_say(xhtml.convert_simple_to_full_colors(txt)) + self.cancel_paused_delay() + + @command_args_parser.raw + def command_xhtml(self, xhtml): + """" + /xhtml <custom xhtml> + """ + message = self.generate_xhtml_message(xhtml) + if message: + message.send() + + def generate_xhtml_message(self, arg): + if not arg: + return + try: + body = xhtml.clean_text(xhtml.xhtml_to_poezio_colors(arg)) + ET.fromstring(arg) + except: + self.core.information('Could not send custom xhtml', 'Error') + log.error('/xhtml: Unable to send custom xhtml', exc_info=True) + return + + msg = self.core.xmpp.make_message(self.get_dest_jid()) + msg['body'] = body + msg.enable('html') + msg['html']['body'] = arg + return msg + + def get_dest_jid(self): + return self.name + + @refresh_wrapper.always + def command_clear(self, ignored): + """ + /clear + """ + self._text_buffer.messages = [] + self.text_win.rebuild_everything(self._text_buffer) + + def send_chat_state(self, state, always_send=False): + """ + Send an empty chatstate message + """ + if not self.is_muc or self.joined: + if state in ('active', 'inactive', 'gone') and self.inactive and not always_send: + return + if (config.get_by_tabname('send_chat_states', self.general_jid) + and self.remote_wants_chatstates is not False): + msg = self.core.xmpp.make_message(self.get_dest_jid()) + msg['type'] = self.message_type + msg['chat_state'] = state + self.chat_state = state + msg.send() + return True + + def send_composing_chat_state(self, empty_after): + """ + Send the "active" or "composing" chatstate, depending + on the the current status of the input + """ + name = self.general_jid + if (config.get_by_tabname('send_chat_states', name) + and self.remote_wants_chatstates): + needed = 'inactive' if self.inactive else 'active' + self.cancel_paused_delay() + if not empty_after: + if self.chat_state != "composing": + self.send_chat_state("composing") + self.set_paused_delay(True) + elif empty_after and self.chat_state != needed: + self.send_chat_state(needed, True) + + def set_paused_delay(self, composing): + """ + we create a timed event that will put us to paused + in a few seconds + """ + if not config.get_by_tabname('send_chat_states', self.general_jid): + return + # First, cancel the delay if it already exists, before rescheduling + # it at a new date + self.cancel_paused_delay() + new_event = timed_events.DelayedEvent(4, self.send_chat_state, 'paused') + self.core.add_timed_event(new_event) + self.timed_event_paused = new_event + + def cancel_paused_delay(self): + """ + Remove that event from the list and set it to None. + Called for example when the input is emptied, or when the message + is sent + """ + if self.timed_event_paused is not None: + self.core.remove_timed_event(self.timed_event_paused) + self.timed_event_paused = None + + @command_args_parser.raw + def command_correct(self, line): + """ + /correct <fixed message> + """ + if not line: + self.core.command_help('correct') + return + if not self.last_sent_message: + self.core.information('There is no message to correct.') + return + self.command_say(line, correct=True) + + def completion_correct(self, the_input): + if self.last_sent_message and the_input.get_argument_position() == 1: + return the_input.auto_completion([self.last_sent_message['body']], '', quotify=False) + + @property + def inactive(self): + """Whether we should send inactive or active as a chatstate""" + return self.core.status.show in ('xa', 'away') or\ + (hasattr(self, 'directed_presence') and not self.directed_presence) + + def move_separator(self): + self.text_win.remove_line_separator() + self.text_win.add_line_separator(self._text_buffer) + self.text_win.refresh() + self.input.refresh() + + def get_conversation_messages(self): + return self._text_buffer.messages + + def check_scrolled(self): + if self.text_win.pos != 0: + self.state = 'scrolled' + + @command_args_parser.raw + def command_say(self, line, correct=False): + pass + + def on_line_up(self): + return self.text_win.scroll_up(1) + + def on_line_down(self): + return self.text_win.scroll_down(1) + + def on_scroll_up(self): + return self.text_win.scroll_up(self.text_win.height-1) + + def on_scroll_down(self): + return self.text_win.scroll_down(self.text_win.height-1) + + def on_half_scroll_up(self): + return self.text_win.scroll_up((self.text_win.height-1) // 2) + + def on_half_scroll_down(self): + return self.text_win.scroll_down((self.text_win.height-1) // 2) + + @refresh_wrapper.always + def scroll_separator(self): + self.text_win.scroll_to_separator() + +class OneToOneTab(ChatTab): + + def __init__(self, jid=''): + ChatTab.__init__(self, jid) + + # Set to true once the first disco is done + self.__initial_disco = False + # change this to True or False when + # we know that the remote user wants chatstates, or not. + # None means we don’t know yet, and we send only "active" chatstates + self._remote_wants_chatstates = None + self.remote_supports_attention = True + self.remote_supports_receipts = True + self.check_features() + + @property + def remote_wants_chatstates(self): + return self._remote_wants_chatstates + + @remote_wants_chatstates.setter + def remote_wants_chatstates(self, value): + old_value = self._remote_wants_chatstates + self._remote_wants_chatstates = value + if (old_value is None and value != None) or \ + (old_value != value and value != None): + ok = get_theme().CHAR_OK + nope = get_theme().CHAR_EMPTY + support = ok if value else nope + if value: + msg = '\x19%s}Contact supports chat states [%s].' + else: + msg = '\x19%s}Contact does not support chat states [%s].' + color = dump_tuple(get_theme().COLOR_INFORMATION_TEXT) + msg = msg % (color, support) + self.add_message(msg, typ=0) + self.core.refresh_window() + + def ack_message(self, msg_id, msg_jid): + """ + Ack a message + """ + new_msg = self._text_buffer.ack_message(msg_id, msg_jid) + if new_msg: + self.text_win.modify_message(msg_id, new_msg) + self.core.refresh_window() + + def nack_message(self, error, msg_id, msg_jid): + """ + Ack a message + """ + new_msg = self._text_buffer.nack_message(error, msg_id, msg_jid) + if new_msg: + self.text_win.modify_message(msg_id, new_msg) + self.core.refresh_window() + return True + return False + + @command_args_parser.raw + def command_xhtml(self, xhtml_data): + message = self.generate_xhtml_message(xhtml_data) + if message: + message['type'] = 'chat' + if self.remote_supports_receipts: + message._add_receipt = True + if self.remote_wants_chatstates: + message['chat_sate'] = 'active' + message.send() + body = xhtml.xhtml_to_poezio_colors(xhtml_data, force=True) + self._text_buffer.add_message(body, nickname=self.core.own_nick, + identifier=message['id'],) + self.refresh() + + def check_features(self): + "check the features supported by the other party" + if safeJID(self.get_dest_jid()).resource: + self.core.xmpp.plugin['xep_0030'].get_info( + jid=self.get_dest_jid(), timeout=5, + callback=self.features_checked) + + @command_args_parser.raw + def command_attention(self, message): + """/attention [message]""" + if message is not '': + self.command_say(message, attention=True) + else: + msg = self.core.xmpp.make_message(self.get_dest_jid()) + msg['type'] = 'chat' + msg['attention'] = True + msg.send() + + @command_args_parser.raw + def command_say(self, line, correct=False, attention=False): + pass + + def missing_command_callback(self, command_name): + if command_name not in ('correct', 'attention'): + return False + + if command_name == 'correct': + feature = 'message correction' + elif command_name == 'attention': + feature = 'attention requests' + msg = ('%s does not support %s, therefore the /%s ' + 'command is currently disabled in this tab.') + msg = msg % (self.name, feature, command_name) + self.core.information(msg, 'Info') + return True + + def _feature_attention(self, features): + "Check for the 'attention' features" + if 'urn:xmpp:attention:0' in features: + self.remote_supports_attention = True + self.register_command('attention', self.command_attention, + usage='[message]', + shortdesc='Request the attention.', + desc='Attention: Request the attention of ' + 'the contact. Can also send a message' + ' along with the attention.') + else: + self.remote_supports_attention = False + return self.remote_supports_attention + + def _feature_correct(self, features): + "Check for the 'correction' feature" + if not 'urn:xmpp:message-correct:0' in features: + if 'correct' in self.commands: + del self.commands['correct'] + elif not 'correct' in self.commands: + self.register_command('correct', self.command_correct, + desc='Fix the last message with whatever you want.', + shortdesc='Correct the last message.', + completion=self.completion_correct) + return 'correct' in self.commands + + def _feature_receipts(self, features): + "Check for the 'receipts' feature" + if 'urn:xmpp:receipts' in features: + self.remote_supports_receipts = True + else: + self.remote_supports_receipts = False + return self.remote_supports_receipts + + def features_checked(self, iq): + "Features check callback" + features = iq['disco_info'].get_features() or [] + before = ('correct' in self.commands, + self.remote_supports_attention, + self.remote_supports_receipts) + correct = self._feature_correct(features) + attention = self._feature_attention(features) + receipts = self._feature_receipts(features) + + if (correct, attention, receipts) == before and self.__initial_disco: + return + else: + self.__initial_disco = True + + if not (correct or attention or receipts): + return # don’t display anything + + ok = get_theme().CHAR_OK + nope = get_theme().CHAR_EMPTY + + correct = ok if correct else nope + attention = ok if attention else nope + receipts = ok if receipts else nope + + msg = ('\x19%s}Contact supports: correction [%s], ' + 'attention [%s], receipts [%s].') + color = dump_tuple(get_theme().COLOR_INFORMATION_TEXT) + msg = msg % (color, correct, attention, receipts) + self.add_message(msg, typ=0) + self.core.refresh_window() + + diff --git a/poezio/tabs/bookmarkstab.py b/poezio/tabs/bookmarkstab.py new file mode 100644 index 00000000..7f5069ea --- /dev/null +++ b/poezio/tabs/bookmarkstab.py @@ -0,0 +1,145 @@ +""" +Defines the data-forms Tab +""" + +import logging +log = logging.getLogger(__name__) + +import windows +from bookmarks import Bookmark, BookmarkList, stanza_storage +from tabs import Tab +from common import safeJID + + +class BookmarksTab(Tab): + """ + A tab displaying lines of bookmarks, each bookmark having + a 4 widgets to set the jid/password/autojoin/storage method + """ + plugin_commands = {} + def __init__(self, bookmarks: BookmarkList): + Tab.__init__(self) + self.name = "Bookmarks" + self.bookmarks = bookmarks + self.new_bookmarks = [] + self.removed_bookmarks = [] + self.header_win = windows.ColumnHeaderWin(('room@server/nickname', + 'password', + 'autojoin', + 'storage')) + self.bookmarks_win = windows.BookmarksWin(self.bookmarks, + self.height-4, + self.width, 1, 0) + self.help_win = windows.HelpText('Ctrl+Y: save, Ctrl+G: cancel, ' + '↑↓: change lines, tab: change ' + 'column, M-a: add bookmark, C-k' + ': delete bookmark') + self.info_header = windows.BookmarksInfoWin() + self.key_func['KEY_UP'] = self.bookmarks_win.go_to_previous_line_input + self.key_func['KEY_DOWN'] = self.bookmarks_win.go_to_next_line_input + self.key_func['^I'] = self.bookmarks_win.go_to_next_horizontal_input + self.key_func['^G'] = self.on_cancel + self.key_func['^Y'] = self.on_save + self.key_func['M-a'] = self.add_bookmark + self.key_func['^K'] = self.del_bookmark + self.resize() + self.update_commands() + + def add_bookmark(self): + new_bookmark = Bookmark(safeJID('room@example.tld/nick'), method='local') + self.new_bookmarks.append(new_bookmark) + self.bookmarks_win.add_bookmark(new_bookmark) + + def del_bookmark(self): + current = self.bookmarks_win.del_current_bookmark() + if current in self.new_bookmarks: + self.new_bookmarks.remove(current) + else: + self.removed_bookmarks.append(current) + + def on_cancel(self): + self.core.close_tab() + return True + + def on_save(self): + self.bookmarks_win.save() + if find_duplicates(self.new_bookmarks): + self.core.information('Duplicate bookmarks in list (saving aborted)', 'Error') + return + for bm in self.new_bookmarks: + if safeJID(bm.jid): + if not self.bookmarks[bm.jid]: + self.bookmarks.append(bm) + else: + self.core.information('Invalid JID for bookmark: %s/%s' % (bm.jid, bm.nick), 'Error') + return + + for bm in self.removed_bookmarks: + if bm in self.bookmarks: + self.bookmarks.remove(bm) + + def send_cb(success): + if success: + self.core.information('Bookmarks saved.', 'Info') + else: + self.core.information('Remote bookmarks not saved.', 'Error') + log.debug('alerte %s', str(stanza_storage(self.bookmarks.bookmarks))) + self.bookmarks.save(self.core.xmpp, callback=send_cb) + self.core.close_tab() + return True + + def on_input(self, key, raw=False): + if key in self.key_func: + res = self.key_func[key]() + if res: + return res + self.bookmarks_win.refresh_current_input() + else: + self.bookmarks_win.on_input(key) + + def resize(self): + self.need_resize = False + self.header_win.resize_columns({ + 'room@server/nickname': self.width//3, + 'password': self.width//3, + 'autojoin': self.width//6, + 'storage': self.width//6 + }) + info_height = self.core.information_win_size + tab_height = Tab.tab_win_height() + self.header_win.resize(1, self.width, 0, 0) + self.bookmarks_win.resize(self.height - 3 - tab_height - info_height, + self.width, 1, 0) + self.help_win.resize(1, self.width, self.height - 1, 0) + self.info_header.resize(1, self.width, + self.height - 2 - tab_height - info_height, 0) + + def on_info_win_size_changed(self): + if self.core.information_win_size >= self.height - 3: + return + info_height = self.core.information_win_size + tab_height = Tab.tab_win_height() + self.bookmarks_win.resize(self.height - 3 - tab_height - info_height, + self.width, 1, 0) + self.info_header.resize(1, self.width, + self.height - 2 - tab_height - info_height, 0) + + def refresh(self): + if self.need_resize: + self.resize() + self.header_win.refresh() + self.refresh_tab_win() + self.help_win.refresh() + self.info_header.refresh(self.bookmarks.preferred) + self.info_win.refresh() + self.bookmarks_win.refresh() + + +def find_duplicates(bm_list): + jids = set() + for bookmark in bm_list: + if bookmark.jid in jids: + return True + jids.add(bookmark.jid) + return False + diff --git a/poezio/tabs/conversationtab.py b/poezio/tabs/conversationtab.py new file mode 100644 index 00000000..1d8c60a4 --- /dev/null +++ b/poezio/tabs/conversationtab.py @@ -0,0 +1,484 @@ +""" +Module for the ConversationTabs + +A ConversationTab is a direct chat between two JIDs, outside of a room. + +There are two different instances of a ConversationTab: +- A DynamicConversationTab that implements XEP-0296 (best practices for + resource locking), which means it will switch the resource it is + focused on depending on the presences received. This is the default. +- A StaticConversationTab that will stay focused on one resource all + the time. + +""" +import logging +log = logging.getLogger(__name__) + +import curses + +from . basetabs import OneToOneTab, Tab + +import common +import fixes +import windows +import xhtml +from common import safeJID +from config import config +from decorators import refresh_wrapper +from roster import roster +from theming import get_theme, dump_tuple +from decorators import command_args_parser + +class ConversationTab(OneToOneTab): + """ + The tab containg a normal conversation (not from a MUC) + Must not be instantiated, use Static or Dynamic version only. + """ + plugin_commands = {} + plugin_keys = {} + additional_informations = {} + message_type = 'chat' + def __init__(self, jid): + OneToOneTab.__init__(self, jid) + self.nick = None + self.nick_sent = False + self.state = 'normal' + self.name = jid # a conversation tab is linked to one specific full jid OR bare jid + self.text_win = windows.TextWin() + self._text_buffer.add_window(self.text_win) + self.upper_bar = windows.ConversationStatusMessageWin() + self.input = windows.MessageInput() + # keys + self.key_func['^I'] = self.completion + # commands + self.register_command('unquery', self.command_unquery, + shortdesc='Close the tab.') + self.register_command('close', self.command_unquery, + shortdesc='Close the tab.') + self.register_command('version', self.command_version, + desc='Get the software version of the current interlocutor (usually its XMPP client and Operating System).', + shortdesc='Get the software version of the user.') + self.register_command('info', self.command_info, + shortdesc='Get the status of the contact.') + self.register_command('last_activity', self.command_last_activity, + usage='[jid]', + desc='Get the last activity of the given or the current contact.', + shortdesc='Get the activity.', + completion=self.core.completion_last_activity) + self.resize() + self.update_commands() + self.update_keys() + + @property + def general_jid(self): + return safeJID(self.name).bare + + @staticmethod + def add_information_element(plugin_name, callback): + """ + Lets a plugin add its own information to the ConversationInfoWin + """ + ConversationTab.additional_informations[plugin_name] = callback + + @staticmethod + def remove_information_element(plugin_name): + del ConversationTab.additional_informations[plugin_name] + + def completion(self): + self.complete_commands(self.input) + + @command_args_parser.raw + def command_say(self, line, attention=False, correct=False): + msg = self.core.xmpp.make_message(self.get_dest_jid()) + msg['type'] = 'chat' + msg['body'] = line + if not self.nick_sent: + msg['nick'] = self.core.own_nick + self.nick_sent = True + # trigger the event BEFORE looking for colors. + # and before displaying the message in the window + # This lets a plugin insert \x19xxx} colors, that will + # be converted in xhtml. + self.core.events.trigger('conversation_say', msg, self) + if not msg['body']: + self.cancel_paused_delay() + self.text_win.refresh() + self.input.refresh() + return + replaced = False + if correct or msg['replace']['id']: + msg['replace']['id'] = self.last_sent_message['id'] + if config.get_by_tabname('group_corrections', self.name): + try: + self.modify_message(msg['body'], self.last_sent_message['id'], msg['id'], jid=self.core.xmpp.boundjid, + nickname=self.core.own_nick) + replaced = True + except: + log.error('Unable to correct a message', exc_info=True) + else: + del msg['replace'] + if msg['body'].find('\x19') != -1: + msg.enable('html') + msg['html']['body'] = xhtml.poezio_colors_to_html(msg['body']) + msg['body'] = xhtml.clean_text(msg['body']) + if (config.get_by_tabname('send_chat_states', self.general_jid) and + self.remote_wants_chatstates is not False): + needed = 'inactive' if self.inactive else 'active' + msg['chat_state'] = needed + if attention and self.remote_supports_attention: + msg['attention'] = True + self.core.events.trigger('conversation_say_after', msg, self) + if not msg['body']: + self.cancel_paused_delay() + self.text_win.refresh() + self.input.refresh() + return + if not replaced: + self.add_message(msg['body'], + nickname=self.core.own_nick, + nick_color=get_theme().COLOR_OWN_NICK, + identifier=msg['id'], + jid=self.core.xmpp.boundjid, + typ=1) + + self.last_sent_message = msg + if self.remote_supports_receipts: + msg._add_receipt = True + msg.send() + self.cancel_paused_delay() + self.text_win.refresh() + self.input.refresh() + + @command_args_parser.quoted(0, 1) + def command_last_activity(self, args): + """ + /last_activity [jid] + """ + if args and args[0]: + return self.core.command_last_activity(args[0]) + + def callback(iq): + if iq['type'] != 'result': + if iq['error']['type'] == 'auth': + self.core.information('You are not allowed to see the activity of this contact.', 'Error') + else: + self.core.information('Error retrieving the activity', 'Error') + return + seconds = iq['last_activity']['seconds'] + status = iq['last_activity']['status'] + from_ = iq['from'] + msg = '\x19%s}The last activity of %s was %s ago%s' + if not safeJID(from_).user: + msg = '\x19%s}The uptime of %s is %s.' % ( + dump_tuple(get_theme().COLOR_INFORMATION_TEXT), + from_, + common.parse_secs_to_str(seconds)) + else: + msg = '\x19%s}The last activity of %s was %s ago%s' % ( + dump_tuple(get_theme().COLOR_INFORMATION_TEXT), + from_, + common.parse_secs_to_str(seconds), + (' and his/her last status was %s' % status) if status else '',) + self.add_message(msg) + self.core.refresh_window() + + self.core.xmpp.plugin['xep_0012'].get_last_activity(self.get_dest_jid(), callback=callback) + + @refresh_wrapper.conditional + @command_args_parser.ignored + def command_info(self): + contact = roster[self.get_dest_jid()] + jid = safeJID(self.get_dest_jid()) + if contact: + if jid.resource: + resource = contact[jid.full] + else: + resource = contact.get_highest_priority_resource() + else: + resource = None + if resource: + status = ('Status: %s' % resource.status) if resource.status else '' + self._text_buffer.add_message("\x19%(info_col)s}Show: %(show)s, %(status)s\x19o" % { + 'show': resource.show or 'available', 'status': status, 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)}) + return True + else: + self._text_buffer.add_message("\x19%(info_col)s}No information available\x19o" % {'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)}) + return True + + @command_args_parser.ignored + def command_unquery(self): + self.core.close_tab() + + @command_args_parser.quoted(0, 1) + def command_version(self, args): + """ + /version [jid] + """ + def callback(res): + if not res: + return self.core.information('Could not get the software version from %s' % (jid,), 'Warning') + version = '%s is running %s version %s on %s' % (jid, + res.get('name') or 'an unknown software', + res.get('version') or 'unknown', + res.get('os') or 'an unknown platform') + self.core.information(version, 'Info') + if args: + return self.core.command_version(args[0]) + jid = safeJID(self.name) + if not jid.resource: + if jid in roster: + resource = roster[jid].get_highest_priority_resource() + jid = resource.jid if resource else jid + fixes.get_version(self.core.xmpp, jid, + callback=callback) + + def resize(self): + self.need_resize = False + if self.size.tab_degrade_y: + display_bar = False + info_win_height = 0 + tab_win_height = 0 + bar_height = 0 + else: + display_bar = True + info_win_height = self.core.information_win_size + tab_win_height = Tab.tab_win_height() + bar_height = 1 + + self.text_win.resize(self.height - 2 - bar_height - info_win_height + - tab_win_height, + self.width, bar_height, 0) + self.text_win.rebuild_everything(self._text_buffer) + if display_bar: + self.upper_bar.resize(1, self.width, 0, 0) + self.info_header.resize(1, self.width, + self.height - 2 - info_win_height + - tab_win_height, + 0) + self.input.resize(1, self.width, self.height - 1, 0) + + def refresh(self): + if self.need_resize: + self.resize() + log.debug(' TAB Refresh: %s', self.__class__.__name__) + display_bar = display_info_win = not self.size.tab_degrade_y + + self.text_win.refresh() + + if display_bar: + self.upper_bar.refresh(self.get_dest_jid(), roster[self.get_dest_jid()]) + self.info_header.refresh(self.get_dest_jid(), roster[self.get_dest_jid()], self.text_win, self.chatstate, ConversationTab.additional_informations) + + if display_info_win: + self.info_win.refresh() + self.refresh_tab_win() + self.input.refresh() + + def refresh_info_header(self): + self.info_header.refresh(self.get_dest_jid(), roster[self.get_dest_jid()], + self.text_win, self.chatstate, ConversationTab.additional_informations) + self.input.refresh() + + def get_nick(self): + jid = safeJID(self.name) + contact = roster[jid.bare] + if contact: + return contact.name or jid.user + else: + if self.nick: + return self.nick + return jid.user + + def on_input(self, key, raw): + if not raw and key in self.key_func: + self.key_func[key]() + return False + self.input.do_command(key, raw=raw) + empty_after = self.input.get_text() == '' or (self.input.get_text().startswith('/') and not self.input.get_text().startswith('//')) + self.send_composing_chat_state(empty_after) + return False + + def on_lose_focus(self): + contact = roster[self.get_dest_jid()] + jid = safeJID(self.get_dest_jid()) + if contact: + if jid.resource: + resource = contact[jid.full] + else: + resource = contact.get_highest_priority_resource() + else: + resource = None + if self.input.text: + self.state = 'nonempty' + else: + self.state = 'normal' + self.text_win.remove_line_separator() + self.text_win.add_line_separator(self._text_buffer) + if (config.get_by_tabname('send_chat_states', self.general_jid) + and (not self.input.get_text() + or not self.input.get_text().startswith('//'))): + if resource: + self.send_chat_state('inactive') + self.check_scrolled() + + def on_gain_focus(self): + contact = roster[self.get_dest_jid()] + jid = safeJID(self.get_dest_jid()) + if contact: + if jid.resource: + resource = contact[jid.full] + else: + resource = contact.get_highest_priority_resource() + else: + resource = None + + self.state = 'current' + curses.curs_set(1) + if (config.get_by_tabname('send_chat_states', self.general_jid) + and (not self.input.get_text() + or not self.input.get_text().startswith('//'))): + if resource: + self.send_chat_state('active') + + def on_info_win_size_changed(self): + if self.core.information_win_size >= self.height-3: + return + self.text_win.resize(self.height-3-self.core.information_win_size - Tab.tab_win_height(), self.width, 1, 0) + self.info_header.resize(1, self.width, self.height-2-self.core.information_win_size - Tab.tab_win_height(), 0) + + def get_text_window(self): + return self.text_win + + def on_close(self): + Tab.on_close(self) + if config.get_by_tabname('send_chat_states', self.general_jid): + self.send_chat_state('gone') + + def matching_names(self): + res = [] + jid = safeJID(self.name) + res.append((2, jid.bare)) + res.append((1, jid.user)) + contact = roster[self.name] + if contact and contact.name: + res.append((0, contact.name)) + return res + +class DynamicConversationTab(ConversationTab): + """ + A conversation tab associated with one bare JID that can be “locked” to + a full jid, and unlocked, as described in the XEP-0296. + Only one DynamicConversationTab can be opened for a given jid. + """ + def __init__(self, jid, resource=None): + self.locked_resource = None + self.name = safeJID(jid).bare + if resource: + self.lock(resource) + self.info_header = windows.DynamicConversationInfoWin() + ConversationTab.__init__(self, jid) + self.register_command('unlock', self.unlock_command, + shortdesc='Unlock the conversation from a particular resource.') + + def lock(self, resource): + """ + Lock the tab to the resource. + """ + assert(resource) + if resource != self.locked_resource: + self.locked_resource = resource + info = '\x19%s}' % dump_tuple(get_theme().COLOR_INFORMATION_TEXT) + jid_c = '\x19%s}' % dump_tuple(get_theme().COLOR_MUC_JID) + + message = ('%(info)sConversation locked to ' + '%(jid_c)s%(jid)s/%(resource)s%(info)s.') % { + 'info': info, + 'jid_c': jid_c, + 'jid': self.name, + 'resource': resource} + self.add_message(message, typ=0) + self.check_features() + + def unlock_command(self, arg=None): + self.unlock() + self.refresh_info_header() + + def unlock(self, from_=None): + """ + Unlock the tab from a resource. It is now “associated” with the bare + jid. + """ + self.remote_wants_chatstates = None + if self.locked_resource != None: + self.locked_resource = None + info = '\x19%s}' % dump_tuple(get_theme().COLOR_INFORMATION_TEXT) + jid_c = '\x19%s}' % dump_tuple(get_theme().COLOR_MUC_JID) + + if from_: + message = ('%(info)sConversation unlocked (received activity' + ' from %(jid_c)s%(jid)s%(info)s).') % { + 'info': info, + 'jid_c': jid_c, + 'jid': from_} + self.add_message(message, typ=0) + else: + message = '%sConversation unlocked.' % info + self.add_message(message, typ=0) + + def get_dest_jid(self): + """ + Returns the full jid (using the locked resource), or the bare jid if + the conversation is not locked. + """ + if self.locked_resource: + return "%s/%s" % (self.name, self.locked_resource) + return self.name + + def refresh(self): + """ + Different from the parent class only for the info_header object. + """ + if self.need_resize: + self.resize() + log.debug(' TAB Refresh: %s', self.__class__.__name__) + display_bar = display_info_win = not self.size.tab_degrade_y + + self.text_win.refresh() + if display_bar: + self.upper_bar.refresh(self.name, roster[self.name]) + if self.locked_resource: + displayed_jid = "%s/%s" % (self.name, self.locked_resource) + else: + displayed_jid = self.name + self.info_header.refresh(displayed_jid, roster[self.name], + self.text_win, self.chatstate, + ConversationTab.additional_informations) + if display_info_win: + self.info_win.refresh() + + self.refresh_tab_win() + self.input.refresh() + + def refresh_info_header(self): + """ + Different from the parent class only for the info_header object. + """ + if self.locked_resource: + displayed_jid = "%s/%s" % (self.name, self.locked_resource) + else: + displayed_jid = self.name + self.info_header.refresh(displayed_jid, roster[self.name], + self.text_win, self.chatstate, ConversationTab.additional_informations) + self.input.refresh() + +class StaticConversationTab(ConversationTab): + """ + A conversation tab associated with one Full JID. It cannot be locked to + an different resource or unlocked. + """ + def __init__(self, jid): + assert(safeJID(jid).resource) + self.info_header = windows.ConversationInfoWin() + ConversationTab.__init__(self, jid) + + diff --git a/poezio/tabs/data_forms.py b/poezio/tabs/data_forms.py new file mode 100644 index 00000000..0fad2974 --- /dev/null +++ b/poezio/tabs/data_forms.py @@ -0,0 +1,75 @@ +""" +Defines the data-forms Tab +""" + +import logging +log = logging.getLogger(__name__) + +import windows +from tabs import Tab + +class DataFormsTab(Tab): + """ + A tab contaning various window type, displaying + a form that the user needs to fill. + """ + plugin_commands = {} + def __init__(self, form, on_cancel, on_send, kwargs): + Tab.__init__(self) + self._form = form + self._on_cancel = on_cancel + self._on_send = on_send + self._kwargs = kwargs + self.fields = [] + for field in self._form: + self.fields.append(field) + self.topic_win = windows.Topic() + self.form_win = windows.FormWin(form, self.height-4, self.width, 1, 0) + self.help_win = windows.HelpText("Ctrl+Y: send form, Ctrl+G: cancel") + self.help_win_dyn = windows.HelpText() + self.key_func['KEY_UP'] = self.form_win.go_to_previous_input + self.key_func['KEY_DOWN'] = self.form_win.go_to_next_input + self.key_func['^G'] = self.on_cancel + self.key_func['^Y'] = self.on_send + self.resize() + self.update_commands() + + def on_cancel(self): + self._on_cancel(self._form, **self._kwargs) + return True + + def on_send(self): + self._form.reply() + self.form_win.reply() + self._on_send(self._form, **self._kwargs) + return True + + def on_input(self, key, raw=False): + if key in self.key_func: + res = self.key_func[key]() + if res: + return res + self.help_win_dyn.refresh(self.form_win.get_help_message()) + self.form_win.refresh_current_input() + else: + self.form_win.on_input(key) + + def resize(self): + self.need_resize = False + self.topic_win.resize(1, self.width, 0, 0) + self.form_win.resize(self.height - 3 - Tab.tab_win_height(), + self.width, 1, 0) + self.help_win.resize(1, self.width, self.height - 1, 0) + self.help_win_dyn.resize(1, self.width, + self.height - 2 - Tab.tab_win_height(), 0) + self.lines = [] + + def refresh(self): + if self.need_resize: + self.resize() + self.topic_win.refresh(self._form['title']) + self.refresh_tab_win() + self.help_win.refresh() + self.help_win_dyn.refresh(self.form_win.get_help_message()) + self.form_win.refresh() + diff --git a/poezio/tabs/listtab.py b/poezio/tabs/listtab.py new file mode 100644 index 00000000..4d8bab9c --- /dev/null +++ b/poezio/tabs/listtab.py @@ -0,0 +1,202 @@ +""" +A generic tab that displays a serie of items in a scrollable, searchable, +sortable list. It should be inherited, to actually provide methods that +insert items in the list, and that lets the user interact with them. +""" + +import logging +log = logging.getLogger(__name__) + +import curses +import collections + +import windows +from common import safeJID +from decorators import refresh_wrapper + +from . import Tab + + +class ListTab(Tab): + plugin_commands = {} + plugin_keys = {} + + def __init__(self, name, help_message, header_text, cols): + """Parameters: + name: The name of the tab + help_message: The default help message displayed instead of the + input + header_text: The text displayed on the header line, at the top of + the tab + cols: a tuple of 2-tuples. e.g. (('column1_name', number), + ('column2_name', number)) + """ + Tab.__init__(self) + self.state = 'normal' + self.name = name + columns = collections.OrderedDict() + for col, num in cols: + columns[col] = num + self.list_header = windows.ColumnHeaderWin(list(columns)) + self.listview = windows.ListWin(columns) + self.info_header = windows.MucListInfoWin(header_text) + self.default_help_message = windows.HelpText(help_message) + self.input = self.default_help_message + self.key_func["KEY_DOWN"] = self.move_cursor_down + self.key_func["KEY_UP"] = self.move_cursor_up + self.key_func['^I'] = self.completion + self.key_func["/"] = self.on_slash + self.key_func['KEY_LEFT'] = self.list_header.sel_column_left + self.key_func['KEY_RIGHT'] = self.list_header.sel_column_right + self.key_func[' '] = self.sort_by + self.register_command('close', self.close, + shortdesc='Close this tab.') + self.resize() + self.update_keys() + self.update_commands() + + def get_columns_sizes(self): + """ + Must be implemented in subclasses. Must return a dict like this: + {'column1_name': size1, + 'column2_name': size2} + Where the size are calculated based on the size of the tab etc + """ + raise NotImplementedError + + + def refresh(self): + if self.need_resize: + self.resize() + log.debug(' TAB Refresh: %s', self.__class__.__name__) + if self.size.tab_degrade_y: + display_info_win = False + else: + display_info_win = True + + self.info_header.refresh(window=self.listview) + if display_info_win: + self.info_win.refresh() + self.refresh_tab_win() + self.list_header.refresh() + self.listview.refresh() + self.input.refresh() + + def resize(self): + if self.size.tab_degrade_y: + info_win_height = 0 + tab_win_height = 0 + else: + info_win_height = self.core.information_win_size + tab_win_height = Tab.tab_win_height() + + self.info_header.resize(1, self.width, + self.height - 2 - info_win_height + - tab_win_height, + 0) + column_size = self.get_columns_sizes() + self.list_header.resize_columns(column_size) + self.list_header.resize(1, self.width, 0, 0) + self.listview.resize_columns(column_size) + self.listview.resize(self.height - 3 - info_win_height - tab_win_height, + self.width, 1, 0) + self.input.resize(1, self.width, self.height-1, 0) + + def on_slash(self): + """ + '/' is pressed, activate the input + """ + curses.curs_set(1) + self.input = windows.CommandInput("", self.reset_help_message, self.execute_slash_command) + self.input.resize(1, self.width, self.height-1, 0) + self.input.do_command("/") # we add the slash + + def close(self, arg=None): + self.input.on_delete() + self.core.close_tab(self) + + def set_error(self, msg, code, body): + """ + If there's an error (retrieving the values etc) + """ + self._error_message = 'Error: %(code)s - %(msg)s: %(body)s' % {'msg':msg, 'body':body, 'code':code} + self.info_header.message = self._error_message + self.info_header.refresh() + curses.doupdate() + + def sort_by(self): + if self.list_header.get_order(): + self.listview.sort_by_column( + col_name=self.list_header.get_sel_column(), + asc=False) + self.list_header.set_order(False) + self.list_header.refresh() + else: + self.listview.sort_by_column( + col_name=self.list_header.get_sel_column(), + asc=True) + self.list_header.set_order(True) + self.list_header.refresh() + self.core.doupdate() + + @refresh_wrapper.always + def reset_help_message(self, _=None): + if self.closed: + return True + curses.curs_set(0) + self.input = self.default_help_message + self.input.resize(1, self.width, self.height-1, 0) + return True + + def execute_slash_command(self, txt): + if txt.startswith('/'): + self.input.key_enter() + self.execute_command(txt) + return self.reset_help_message() + + def completion(self): + if isinstance(self.input, windows.Input): + self.complete_commands(self.input) + + def on_input(self, key, raw): + res = self.input.do_command(key, raw=raw) + if res and not isinstance(self.input, windows.Input): + return True + elif res: + return False + if not raw and key in self.key_func: + return self.key_func[key]() + + def on_info_win_size_changed(self): + if self.core.information_win_size >= self.height-3: + return + self.info_header.resize(1, self.width, self.height-2-self.core.information_win_size - Tab.tab_win_height(), 0) + self.listview.resize(self.height-3-self.core.information_win_size - Tab.tab_win_height(), self.width, 1, 0) + + def on_lose_focus(self): + self.state = 'normal' + + def on_gain_focus(self): + self.state = 'current' + curses.curs_set(0) + + def on_scroll_up(self): + return self.listview.scroll_up() + + def on_scroll_down(self): + return self.listview.scroll_down() + + def move_cursor_up(self): + self.listview.move_cursor_up() + self.listview.refresh() + self.core.doupdate() + + def move_cursor_down(self): + self.listview.move_cursor_down() + self.listview.refresh() + self.core.doupdate() + + def matching_names(self): + return [(2, self.name)] + + diff --git a/poezio/tabs/muclisttab.py b/poezio/tabs/muclisttab.py new file mode 100644 index 00000000..92d55190 --- /dev/null +++ b/poezio/tabs/muclisttab.py @@ -0,0 +1,70 @@ +""" +A MucListTab is a tab listing the rooms on a conference server. + +It has no functionnality except scrolling the list, and allowing the +user to join the rooms. +""" +import logging +log = logging.getLogger(__name__) + +from . import ListTab + +from slixmpp.plugins.xep_0030.stanza.items import DiscoItem + +class MucListTab(ListTab): + """ + A tab listing rooms from a specific server, displaying various information, + scrollable, and letting the user join them, etc + """ + plugin_commands = {} + plugin_keys = {} + + def __init__(self, server): + ListTab.__init__(self, server, + "“j”: join room.", + 'Chatroom list on server %s (Loading)' % server, + (('node-part', 0), ('name', 2), ('users', 3))) + self.key_func['j'] = self.join_selected + self.key_func['J'] = self.join_selected_no_focus + self.key_func['^M'] = self.join_selected + + def get_columns_sizes(self): + return {'node-part': int(self.width* 2 / 8), + 'name': int(self.width * 5 / 8), + 'users': self.width - int(self.width * 2 / 8) + - int(self.width * 5 / 8)} + + def join_selected_no_focus(self): + return + + def on_muc_list_item_received(self, iq): + """ + Callback called when a disco#items result is received + Used with command_list + """ + if iq['type'] == 'error': + self.set_error(iq['error']['type'], iq['error']['code'], iq['error']['text']) + return + def get_items(): + substanza = iq['disco_items'] + for item in substanza['substanzas']: + if isinstance(item, DiscoItem): + yield (item['jid'], item['node'], item['name']) + items = [(item[0].split('@')[0], + item[0], + item[2] or '', '') for item in get_items()] + self.listview.set_lines(items) + self.info_header.message = 'Chatroom list on server %s' % self.name + if self.core.current_tab() is self: + self.refresh() + else: + self.state = 'highlight' + self.refresh_tab_win() + self.core.doupdate() + + def join_selected(self): + row = self.listview.get_selected_row() + if not row: + return + self.core.command_join(row[1]) + diff --git a/poezio/tabs/muctab.py b/poezio/tabs/muctab.py new file mode 100644 index 00000000..1f3ec6d8 --- /dev/null +++ b/poezio/tabs/muctab.py @@ -0,0 +1,1720 @@ +""" +Module for the MucTab + +A MucTab is a tab for multi-user chats as defined in XEP-0045. + +It keeps track of many things such as part/joins, maintains an +user list, and updates private tabs when necessary. +""" + +import logging +log = logging.getLogger(__name__) + +import bisect +import curses +import os +import random +import re +from datetime import datetime + +from . import ChatTab, Tab + +import common +import fixes +import multiuserchat as muc +import timed_events +import windows +import xhtml +from common import safeJID +from config import config +from decorators import refresh_wrapper, command_args_parser +from logger import logger +from roster import roster +from theming import get_theme, dump_tuple +from user import User + + +SHOW_NAME = { + 'dnd': 'busy', + 'away': 'away', + 'xa': 'not available', + 'chat': 'chatty', + '': 'available' + } + +NS_MUC_USER = 'http://jabber.org/protocol/muc#user' + + +class MucTab(ChatTab): + """ + The tab containing a multi-user-chat room. + It contains an userlist, an input, a topic, an information and a chat zone + """ + message_type = 'groupchat' + plugin_commands = {} + plugin_keys = {} + def __init__(self, jid, nick, password=None): + self.joined = False + ChatTab.__init__(self, jid) + if self.joined == False: + self._state = 'disconnected' + self.own_nick = nick + self.name = jid + self.password = password + self.users = [] + self.privates = [] # private conversations + self.topic = '' + self.topic_from = '' + self.remote_wants_chatstates = True + # Self ping event, so we can cancel it when we leave the room + self.self_ping_event = None + # We send active, composing and paused states to the MUC because + # the chatstate may or may not be filtered by the MUC, + # that’s not our problem. + self.topic_win = windows.Topic() + self.text_win = windows.TextWin() + self._text_buffer.add_window(self.text_win) + self.v_separator = windows.VerticalSeparator() + self.user_win = windows.UserList() + self.info_header = windows.MucInfoWin() + self.input = windows.MessageInput() + self.ignores = [] # set of Users + # keys + self.key_func['^I'] = self.completion + self.key_func['M-u'] = self.scroll_user_list_down + self.key_func['M-y'] = self.scroll_user_list_up + self.key_func['M-n'] = self.go_to_next_hl + self.key_func['M-p'] = self.go_to_prev_hl + # commands + self.register_command('ignore', self.command_ignore, + usage='<nickname>', + desc='Ignore a specified nickname.', + shortdesc='Ignore someone', + completion=self.completion_ignore) + self.register_command('unignore', self.command_unignore, + usage='<nickname>', + desc='Remove the specified nickname from the ignore list.', + shortdesc='Unignore someone.', + completion=self.completion_unignore) + self.register_command('kick', self.command_kick, + usage='<nick> [reason]', + desc='Kick the user with the specified nickname.' + ' You also can give an optional reason.', + shortdesc='Kick someone.', + completion=self.completion_quoted) + self.register_command('ban', self.command_ban, + usage='<nick> [reason]', + desc='Ban the user with the specified nickname.' + ' You also can give an optional reason.', + shortdesc='Ban someone', + completion=self.completion_quoted) + self.register_command('role', self.command_role, + usage='<nick> <role> [reason]', + desc='Set the role of an user. Roles can be:' + ' none, visitor, participant, moderator.' + ' You also can give an optional reason.', + shortdesc='Set the role of an user.', + completion=self.completion_role) + self.register_command('affiliation', self.command_affiliation, + usage='<nick or jid> <affiliation>', + desc='Set the affiliation of an user. Affiliations can be:' + ' outcast, none, member, admin, owner.', + shortdesc='Set the affiliation of an user.', + completion=self.completion_affiliation) + self.register_command('topic', self.command_topic, + usage='<subject>', + desc='Change the subject of the room.', + shortdesc='Change the subject.', + completion=self.completion_topic) + self.register_command('query', self.command_query, + usage='<nick> [message]', + desc='Open a private conversation with <nick>. This nick' + ' has to be present in the room you\'re currently in.' + ' If you specified a message after the nickname, it ' + 'will immediately be sent to this user.', + shortdesc='Query an user.', + completion=self.completion_quoted) + self.register_command('part', self.command_part, + usage='[message]', + desc='Disconnect from a room. You can' + ' specify an optional message.', + shortdesc='Leave the room.') + self.register_command('close', self.command_close, + usage='[message]', + desc='Disconnect from a room and close the tab.' + ' You can specify an optional message if ' + 'you are still connected.', + shortdesc='Close the tab.') + self.register_command('nick', self.command_nick, + usage='<nickname>', + desc='Change your nickname in the current room.', + shortdesc='Change your nickname.', + completion=self.completion_nick) + self.register_command('recolor', self.command_recolor, + usage='[random]', + desc='Re-assign a color to all participants of the' + ' current room, based on the last time they talked.' + ' Use this if the participants currently talking ' + 'have too many identical colors. Use /recolor random' + ' for a non-deterministic result.', + shortdesc='Change the nicks colors.', + completion=self.completion_recolor) + self.register_command('color', self.command_color, + usage='<nick> <color>', + desc='Fix a color for a nick. Use "unset" instead of a color' + ' to remove the attribution', + shortdesc='Fix a color for a nick.', + completion=self.completion_color) + self.register_command('cycle', self.command_cycle, + usage='[message]', + desc='Leave the current room and rejoin it immediately.', + shortdesc='Leave and re-join the room.') + self.register_command('info', self.command_info, + usage='<nickname>', + desc='Display some information about the user ' + 'in the MUC: its/his/her role, affiliation,' + ' status and status message.', + shortdesc='Show an user\'s infos.', + completion=self.completion_info) + self.register_command('configure', self.command_configure, + desc='Configure the current room, through a form.', + shortdesc='Configure the room.') + self.register_command('version', self.command_version, + usage='<jid or nick>', + desc='Get the software version of the given JID' + ' or nick in room (usually its XMPP client' + ' and Operating System).', + shortdesc='Get the software version of a jid.', + completion=self.completion_version) + self.register_command('names', self.command_names, + desc='Get the users in the room with their roles.', + shortdesc='List the users.') + self.register_command('invite', self.command_invite, + desc='Invite a contact to this room', + usage='<jid> [reason]', + shortdesc='Invite a contact to this room', + completion=self.completion_invite) + + if self.core.xmpp.boundjid.server == "gmail.com": #gmail sucks + del self.commands["nick"] + + self.resize() + self.update_commands() + self.update_keys() + + @property + def general_jid(self): + return self.name + + @property + def is_muc(self): + return True + + @property + def last_connection(self): + last_message = self._text_buffer.last_message + if last_message: + return last_message.time + return None + + @refresh_wrapper.always + def go_to_next_hl(self): + """ + Go to the next HL in the room, or the last + """ + self.text_win.next_highlight() + + @refresh_wrapper.always + def go_to_prev_hl(self): + """ + Go to the previous HL in the room, or the first + """ + self.text_win.previous_highlight() + + def completion_version(self, the_input): + """Completion for /version""" + compare_users = lambda x: x.last_talked + userlist = [] + for user in sorted(self.users, key=compare_users, reverse=True): + if user.nick != self.own_nick: + userlist.append(user.nick) + comp = [] + for jid in (jid for jid in roster.jids() if len(roster[jid])): + for resource in roster[jid].resources: + comp.append(resource.jid) + comp.sort() + userlist.extend(comp) + + return the_input.auto_completion(userlist, quotify=False) + + def completion_info(self, the_input): + """Completion for /info""" + compare_users = lambda x: x.last_talked + userlist = [] + for user in sorted(self.users, key=compare_users, reverse=True): + userlist.append(user.nick) + return the_input.auto_completion(userlist, quotify=False) + + def completion_nick(self, the_input): + """Completion for /nick""" + nicks = [os.environ.get('USER'), + config.get('default_nick'), + self.core.get_bookmark_nickname(self.name)] + nicks = [i for i in nicks if i] + return the_input.auto_completion(nicks, '', quotify=False) + + def completion_recolor(self, the_input): + if the_input.get_argument_position() == 1: + return the_input.new_completion(['random'], 1, '', quotify=False) + return True + + def completion_color(self, the_input): + """Completion for /color""" + n = the_input.get_argument_position(quoted=True) + if n == 1: + userlist = [user.nick for user in self.users] + if self.own_nick in userlist: + userlist.remove(self.own_nick) + return the_input.new_completion(userlist, 1, '', quotify=True) + elif n == 2: + colors = [i for i in xhtml.colors if i] + colors.sort() + colors.append('unset') + colors.append('random') + return the_input.new_completion(colors, 2, '', quotify=False) + + def completion_ignore(self, the_input): + """Completion for /ignore""" + userlist = [user.nick for user in self.users] + if self.own_nick in userlist: + userlist.remove(self.own_nick) + userlist.sort() + return the_input.auto_completion(userlist, quotify=False) + + def completion_role(self, the_input): + """Completion for /role""" + n = the_input.get_argument_position(quoted=True) + if n == 1: + userlist = [user.nick for user in self.users] + if self.own_nick in userlist: + userlist.remove(self.own_nick) + return the_input.new_completion(userlist, 1, '', quotify=True) + elif n == 2: + possible_roles = ['none', 'visitor', 'participant', 'moderator'] + return the_input.new_completion(possible_roles, 2, '', + quotify=True) + + def completion_affiliation(self, the_input): + """Completion for /affiliation""" + n = the_input.get_argument_position(quoted=True) + if n == 1: + userlist = [user.nick for user in self.users] + if self.own_nick in userlist: + userlist.remove(self.own_nick) + jidlist = [user.jid.bare for user in self.users] + if self.core.xmpp.boundjid.bare in jidlist: + jidlist.remove(self.core.xmpp.boundjid.bare) + userlist.extend(jidlist) + return the_input.new_completion(userlist, 1, '', quotify=True) + elif n == 2: + possible_affiliations = ['none', 'member', 'admin', + 'owner', 'outcast'] + return the_input.new_completion(possible_affiliations, 2, '', + quotify=True) + + @command_args_parser.quoted(1, 1, ['']) + def command_invite(self, args): + """/invite <jid> [reason]""" + if args is None: + return self.core.command_help('invite') + jid, reason = args + self.core.command_invite('%s %s "%s"' % (jid, self.name, reason)) + + def completion_invite(self, the_input): + """Completion for /invite""" + n = the_input.get_argument_position(quoted=True) + if n == 1: + return the_input.new_completion(roster.jids(), 1, quotify=True) + + def scroll_user_list_up(self): + self.user_win.scroll_up() + self.user_win.refresh(self.users) + self.input.refresh() + + def scroll_user_list_down(self): + self.user_win.scroll_down() + self.user_win.refresh(self.users) + self.input.refresh() + + @command_args_parser.quoted(1) + def command_info(self, args): + """ + /info <nick> + """ + if args is None: + return self.core.command_help('info') + nick = args[0] + user = self.get_user_by_name(nick) + if not user: + return self.core.information("Unknown user: %s" % nick, "Error") + theme = get_theme() + inf = '\x19' + dump_tuple(theme.COLOR_INFORMATION_TEXT) + '}' + if user.jid: + user_jid = '%s (\x19%s}%s\x19o%s)' % ( + inf, + dump_tuple(theme.COLOR_MUC_JID), + user.jid, + inf) + else: + user_jid = '' + info = ('\x19%s}%s\x19o%s%s: show: \x19%s}%s\x19o%s, affiliation:' + ' \x19%s}%s\x19o%s, role: \x19%s}%s\x19o%s') % ( + dump_tuple(user.color), + nick, + user_jid, + inf, + dump_tuple(theme.color_show(user.show)), + user.show or 'Available', + inf, + dump_tuple(theme.color_role(user.role)), + user.affiliation or 'None', + inf, + dump_tuple(theme.color_role(user.role)), + user.role or 'None', + '\n%s' % user.status if user.status else '') + self.add_message(info, typ=0) + self.core.refresh_window() + + @command_args_parser.quoted(0) + def command_configure(self, ignored): + """ + /configure + """ + def on_form_received(form): + if not form: + self.core.information( + 'Could not retrieve the configuration form', + 'Error') + return + self.core.open_new_form(form, self.cancel_config, self.send_config) + + fixes.get_room_form(self.core.xmpp, self.name, on_form_received) + + def cancel_config(self, form): + """ + The user do not want to send his/her config, send an iq cancel + """ + muc.cancel_config(self.core.xmpp, self.name) + self.core.close_tab() + + def send_config(self, form): + """ + The user sends his/her config to the server + """ + muc.configure_room(self.core.xmpp, self.name, form) + self.core.close_tab() + + @command_args_parser.raw + def command_cycle(self, msg): + """/cycle [reason]""" + self.command_part(msg) + self.disconnect() + self.user_win.pos = 0 + self.core.disable_private_tabs(self.name) + self.join() + + def join(self): + """ + Join the room + """ + status = self.core.get_status() + if self.last_connection: + delta = datetime.now() - self.last_connection + seconds = delta.seconds + delta.days * 24 * 3600 + else: + seconds = 0 + muc.join_groupchat(self.core, self.name, self.own_nick, + self.password, + status=status.message, + show=status.show, + seconds=seconds) + + @command_args_parser.quoted(0, 1, ['']) + def command_recolor(self, args): + """ + /recolor [random] + Re-assign color to the participants of the room + """ + deterministic = config.get_by_tabname('deterministic_nick_colors', self.name) + if deterministic: + for user in self.users: + if user.nick == self.own_nick: + continue + color = self.search_for_color(user.nick) + if color != '': + continue + user.set_deterministic_color() + if args[0] == 'random': + self.core.information('"random" was provided, but poezio is ' + 'configured to use deterministic colors', + 'Warning') + self.user_win.refresh(self.users) + self.input.refresh() + return + compare_users = lambda x: x.last_talked + users = list(self.users) + sorted_users = sorted(users, key=compare_users, reverse=True) + full_sorted_users = sorted_users[:] + # search our own user, to remove it from the list + # Also remove users whose color is fixed + for user in full_sorted_users: + color = self.search_for_color(user.nick) + if user.nick == self.own_nick: + sorted_users.remove(user) + user.color = get_theme().COLOR_OWN_NICK + elif color != '': + sorted_users.remove(user) + user.change_color(color, deterministic) + colors = list(get_theme().LIST_COLOR_NICKNAMES) + if args[0] == 'random': + random.shuffle(colors) + for i, user in enumerate(sorted_users): + user.color = colors[i % len(colors)] + self.text_win.rebuild_everything(self._text_buffer) + self.user_win.refresh(self.users) + self.text_win.refresh() + self.input.refresh() + + @command_args_parser.quoted(2, 2, ['']) + def command_color(self, args): + """ + /color <nick> <color> + Fix a color for a nick. + Use "unset" instead of a color to remove the attribution. + User "random" to attribute a random color. + """ + if args is None: + return self.core.command_help('color') + nick = args[0] + color = args[1].lower() + user = self.get_user_by_name(nick) + if not color in xhtml.colors and color not in ('unset', 'random'): + return self.core.information("Unknown color: %s" % color, 'Error') + if user and user.nick == self.own_nick: + return self.core.information("You cannot change the color of your" + " own nick.", 'Error') + if color == 'unset': + if config.remove_and_save(nick, 'muc_colors'): + self.core.information('Color for nick %s unset' % (nick)) + else: + if color == 'random': + color = random.choice(list(xhtml.colors)) + if user: + user.change_color(color) + config.set_and_save(nick, color, 'muc_colors') + nick_color_aliases = config.get_by_tabname('nick_color_aliases', self.name) + if nick_color_aliases: + # if any user in the room has a nick which is an alias of the + # nick, update its color + for tab in self.core.get_tabs(MucTab): + for u in tab.users: + nick_alias = re.sub('^_*', '', u.nick) + nick_alias = re.sub('_*$', '', nick_alias) + if nick_alias == nick: + u.change_color(color) + self.text_win.rebuild_everything(self._text_buffer) + self.user_win.refresh(self.users) + self.text_win.refresh() + self.input.refresh() + + @command_args_parser.quoted(1) + def command_version(self, args): + """ + /version <jid or nick> + """ + def callback(res): + if not res: + return self.core.information('Could not get the software ' + 'version from %s' % (jid,), + 'Warning') + version = '%s is running %s version %s on %s' % ( + jid, + res.get('name') or 'an unknown software', + res.get('version') or 'unknown', + res.get('os') or 'an unknown platform') + self.core.information(version, 'Info') + if args is None: + return self.core.command_help('version') + nick = args[0] + if nick in [user.nick for user in self.users]: + jid = safeJID(self.name).bare + jid = safeJID(jid + '/' + nick) + else: + jid = safeJID(nick) + fixes.get_version(self.core.xmpp, jid, + callback=callback) + + @command_args_parser.quoted(1) + def command_nick(self, args): + """ + /nick <nickname> + """ + if args is None: + return self.core.command_help('nick') + nick = args[0] + if not self.joined: + return self.core.information('/nick only works in joined rooms', + 'Info') + current_status = self.core.get_status() + if not safeJID(self.name + '/' + nick): + return self.core.information('Invalid nick', 'Info') + muc.change_nick(self.core, self.name, nick, + current_status.message, + current_status.show) + + @command_args_parser.quoted(0, 1, ['']) + def command_part(self, args): + """ + /part [msg] + """ + arg = args[0] + msg = None + if self.joined: + info_col = dump_tuple(get_theme().COLOR_INFORMATION_TEXT) + char_quit = get_theme().CHAR_QUIT + spec_col = dump_tuple(get_theme().COLOR_QUIT_CHAR) + + if config.get_by_tabname('display_user_color_in_join_part', + self.general_jid): + color = dump_tuple(get_theme().COLOR_OWN_NICK) + else: + color = 3 + + if arg: + msg = ('\x19%(color_spec)s}%(spec)s\x19%(info_col)s} ' + 'You (\x19%(color)s}%(nick)s\x19%(info_col)s})' + ' left the chatroom' + ' (\x19o%(reason)s\x19%(info_col)s})') % { + 'info_col': info_col, 'reason': arg, + 'spec': char_quit, 'color': color, + 'color_spec': spec_col, + 'nick': self.own_nick, + } + else: + msg = ('\x19%(color_spec)s}%(spec)s\x19%(info_col)s} ' + 'You (\x19%(color)s}%(nick)s\x19%(info_col)s})' + ' left the chatroom') % { + 'info_col': info_col, + 'spec': char_quit, 'color': color, + 'color_spec': spec_col, + 'nick': self.own_nick, + } + + self.add_message(msg, typ=2) + self.disconnect() + muc.leave_groupchat(self.core.xmpp, self.name, self.own_nick, arg) + self.core.disable_private_tabs(self.name, reason=msg) + if self == self.core.current_tab(): + self.refresh() + self.core.doupdate() + + @command_args_parser.raw + def command_close(self, msg): + """ + /close [msg] + """ + self.command_part(msg) + self.core.close_tab() + + @command_args_parser.quoted(1, 1) + def command_query(self, args): + """ + /query <nick> [message] + """ + if args is None: + return self.core.command_help('query') + nick = args[0] + r = None + for user in self.users: + if user.nick == nick: + r = self.core.open_private_window(self.name, user.nick) + if r and len(args) == 2: + msg = args[1] + self.core.current_tab().command_say( + xhtml.convert_simple_to_full_colors(msg)) + if not r: + self.core.information("Cannot find user: %s" % nick, 'Error') + + @command_args_parser.raw + def command_topic(self, subject): + """ + /topic [new topic] + """ + if not subject: + self._text_buffer.add_message( + "\x19%s}The subject of the room is: %s %s" % + (dump_tuple(get_theme().COLOR_INFORMATION_TEXT), + self.topic, + '(set by %s)' % self.topic_from if self.topic_from + else '')) + self.refresh() + return + + muc.change_subject(self.core.xmpp, self.name, subject) + + @command_args_parser.quoted(0) + def command_names(self, args): + """ + /names + """ + if not self.joined: + return + + aff = { + 'owner': get_theme().CHAR_AFFILIATION_OWNER, + 'admin': get_theme().CHAR_AFFILIATION_ADMIN, + 'member': get_theme().CHAR_AFFILIATION_MEMBER, + 'none': get_theme().CHAR_AFFILIATION_NONE, + } + + colors = {} + colors["visitor"] = dump_tuple(get_theme().COLOR_USER_VISITOR) + colors["moderator"] = dump_tuple(get_theme().COLOR_USER_MODERATOR) + colors["participant"] = dump_tuple(get_theme().COLOR_USER_PARTICIPANT) + color_other = dump_tuple(get_theme().COLOR_USER_NONE) + + buff = ['Users: %s \n' % len(self.users)] + for user in self.users: + affiliation = aff.get(user.affiliation, + get_theme().CHAR_AFFILIATION_NONE) + color = colors.get(user.role, color_other) + buff.append('\x19%s}%s\x19o\x19%s}%s\x19o' % ( + color, affiliation, dump_tuple(user.color), user.nick)) + + buff.append('\n') + message = ' '.join(buff) + + self._text_buffer.add_message(message) + self.text_win.refresh() + self.input.refresh() + + def completion_topic(self, the_input): + if the_input.get_argument_position() == 1: + return the_input.auto_completion([self.topic], '', quotify=False) + + def completion_quoted(self, the_input): + """Nick completion, but with quotes""" + if the_input.get_argument_position(quoted=True) == 1: + compare_users = lambda x: x.last_talked + word_list = [] + for user in sorted(self.users, key=compare_users, reverse=True): + if user.nick != self.own_nick: + word_list.append(user.nick) + + return the_input.new_completion(word_list, 1, quotify=True) + + @command_args_parser.quoted(1, 1) + def command_kick(self, args): + """ + /kick <nick> [reason] + """ + if args is None: + return self.core.command_help('kick') + if len(args) == 2: + msg = ' "%s"' % args[1] + else: + msg = '' + self.command_role('"'+args[0]+ '" none'+msg) + + @command_args_parser.quoted(1, 1) + def command_ban(self, args): + """ + /ban <nick> [reason] + """ + def callback(iq): + if iq['type'] == 'error': + self.core.room_error(iq, self.name) + if args is None: + return self.core.command_help('ban') + if len(args) > 1: + msg = args[1] + else: + msg = '' + nick = args[0] + + if nick in [user.nick for user in self.users]: + res = muc.set_user_affiliation(self.core.xmpp, self.name, + 'outcast', nick=nick, + callback=callback, reason=msg) + else: + res = muc.set_user_affiliation(self.core.xmpp, self.name, + 'outcast', jid=safeJID(nick), + callback=callback, reason=msg) + if not res: + self.core.information('Could not ban user', 'Error') + + @command_args_parser.quoted(2, 1, ['']) + def command_role(self, args): + """ + /role <nick> <role> [reason] + Changes the role of an user + roles can be: none, visitor, participant, moderator + """ + def callback(iq): + if iq['type'] == 'error': + self.core.room_error(iq, self.name) + + if args is None: + return self.core.command_help('role') + + nick, role, reason = args[0], args[1].lower(), args[2] + + valid_roles = ('none', 'visitor', 'participant', 'moderator') + + if not self.joined or role not in valid_roles: + return self.core.information('The role must be one of ' + ', '.join(valid_roles), + 'Error') + + if not safeJID(self.name + '/' + nick): + return self.core.information('Invalid nick', 'Info') + muc.set_user_role(self.core.xmpp, self.name, nick, reason, role, + callback=callback) + + @command_args_parser.quoted(2) + def command_affiliation(self, args): + """ + /affiliation <nick> <role> + Changes the affiliation of an user + affiliations can be: outcast, none, member, admin, owner + """ + def callback(iq): + if iq['type'] == 'error': + self.core.room_error(iq, self.name) + + if args is None: + return self.core.command_help('affiliation') + + nick, affiliation = args[0], args[1].lower() + + if not self.joined: + return + + valid_affiliations = ('outcast', 'none', 'member', 'admin', 'owner') + if affiliation not in valid_affiliations: + return self.core.information('The affiliation must be one of ' + ', '.join(valid_affiliations), + 'Error') + + if nick in [user.nick for user in self.users]: + res = muc.set_user_affiliation(self.core.xmpp, self.name, + affiliation, nick=nick, + callback=callback) + else: + res = muc.set_user_affiliation(self.core.xmpp, self.name, + affiliation, jid=safeJID(nick), + callback=callback) + if not res: + self.core.information('Could not set affiliation', 'Error') + + @command_args_parser.raw + def command_say(self, line, correct=False): + """ + /say <message> + Or normal input + enter + """ + needed = 'inactive' if self.inactive else 'active' + msg = self.core.xmpp.make_message(self.name) + msg['type'] = 'groupchat' + msg['body'] = line + # trigger the event BEFORE looking for colors. + # This lets a plugin insert \x19xxx} colors, that will + # be converted in xhtml. + self.core.events.trigger('muc_say', msg, self) + if not msg['body']: + self.cancel_paused_delay() + self.text_win.refresh() + self.input.refresh() + return + if msg['body'].find('\x19') != -1: + msg.enable('html') + msg['html']['body'] = xhtml.poezio_colors_to_html(msg['body']) + msg['body'] = xhtml.clean_text(msg['body']) + if (config.get_by_tabname('send_chat_states', self.general_jid) + and self.remote_wants_chatstates is not False): + msg['chat_state'] = needed + if correct: + msg['replace']['id'] = self.last_sent_message['id'] + self.cancel_paused_delay() + self.core.events.trigger('muc_say_after', msg, self) + if not msg['body']: + self.cancel_paused_delay() + self.text_win.refresh() + self.input.refresh() + return + self.last_sent_message = msg + msg.send() + self.chat_state = needed + + @command_args_parser.raw + def command_xhtml(self, msg): + message = self.generate_xhtml_message(msg) + if message: + message['type'] = 'groupchat' + message.send() + + @command_args_parser.quoted(1) + def command_ignore(self, args): + """ + /ignore <nick> + """ + if args is None: + return self.core.command_help('ignore') + + nick = args[0] + user = self.get_user_by_name(nick) + if not user: + self.core.information('%s is not in the room' % nick) + elif user in self.ignores: + self.core.information('%s is already ignored' % nick) + else: + self.ignores.append(user) + self.core.information("%s is now ignored" % nick, 'info') + + @command_args_parser.quoted(1) + def command_unignore(self, args): + """ + /unignore <nick> + """ + if args is None: + return self.core.command_help('unignore') + + nick = args[0] + user = self.get_user_by_name(nick) + if not user: + self.core.information('%s is not in the room' % nick) + elif user not in self.ignores: + self.core.information('%s is not ignored' % nick) + else: + self.ignores.remove(user) + self.core.information('%s is now unignored' % nick) + + def completion_unignore(self, the_input): + if the_input.get_argument_position() == 1: + users = [user.nick for user in self.ignores] + return the_input.auto_completion(users, quotify=False) + + def resize(self): + """ + Resize the whole window. i.e. all its sub-windows + """ + self.need_resize = False + if config.get('hide_user_list') or self.size.tab_degrade_x: + display_user_list = False + text_width = self.width + else: + display_user_list = True + text_width = (self.width // 10) * 9 + + if self.size.tab_degrade_y: + display_info_win = False + tab_win_height = 0 + info_win_height = 0 + else: + display_info_win = True + tab_win_height = Tab.tab_win_height() + info_win_height = self.core.information_win_size + + + self.user_win.resize(self.height - 3 - info_win_height + - tab_win_height, + self.width - (self.width // 10) * 9 - 1, + 1, + (self.width // 10) * 9 + 1) + self.v_separator.resize(self.height - 3 - info_win_height - tab_win_height, + 1, 1, 9 * (self.width // 10)) + + self.topic_win.resize(1, self.width, 0, 0) + + self.text_win.resize(self.height - 3 - info_win_height + - tab_win_height, + text_width, 1, 0) + self.text_win.rebuild_everything(self._text_buffer) + self.info_header.resize(1, self.width, + self.height - 2 - info_win_height + - tab_win_height, + 0) + self.input.resize(1, self.width, self.height-1, 0) + + def refresh(self): + if self.need_resize: + self.resize() + log.debug(' TAB Refresh: %s', self.__class__.__name__) + if config.get('hide_user_list') or self.size.tab_degrade_x: + display_user_list = False + else: + display_user_list = True + display_info_win = not self.size.tab_degrade_y + + self.topic_win.refresh(self.get_single_line_topic()) + self.text_win.refresh() + if display_user_list: + self.v_separator.refresh() + self.user_win.refresh(self.users) + self.info_header.refresh(self, self.text_win) + self.refresh_tab_win() + if display_info_win: + self.info_win.refresh() + self.input.refresh() + + def on_input(self, key, raw): + if not raw and key in self.key_func: + self.key_func[key]() + return False + self.input.do_command(key, raw=raw) + empty_after = self.input.get_text() == '' + empty_after = empty_after or (self.input.get_text().startswith('/') + and not + self.input.get_text().startswith('//')) + self.send_composing_chat_state(empty_after) + return False + + def completion(self): + """ + Called when Tab is pressed, complete the nickname in the input + """ + if self.complete_commands(self.input): + return + + # If we are not completing a command or a command argument, + # complete a nick + compare_users = lambda x: x.last_talked + word_list = [] + for user in sorted(self.users, key=compare_users, reverse=True): + if user.nick != self.own_nick: + word_list.append(user.nick) + after = config.get('after_completion') + ' ' + input_pos = self.input.pos + if ' ' not in self.input.get_text()[:input_pos] or ( + self.input.last_completion and + self.input.get_text()[:input_pos] == + self.input.last_completion + after): + add_after = after + else: + if not config.get('add_space_after_completion'): + add_after = '' + else: + add_after = ' ' + self.input.auto_completion(word_list, add_after, quotify=False) + empty_after = self.input.get_text() == '' + empty_after = empty_after or (self.input.get_text().startswith('/') + and not + self.input.get_text().startswith('//')) + self.send_composing_chat_state(empty_after) + + def get_nick(self): + if not config.get('show_muc_jid'): + return safeJID(self.name).user + return self.name + + def get_text_window(self): + return self.text_win + + def on_lose_focus(self): + if self.joined: + if self.input.text: + self.state = 'nonempty' + else: + self.state = 'normal' + else: + self.state = 'disconnected' + self.text_win.remove_line_separator() + self.text_win.add_line_separator(self._text_buffer) + if (config.get_by_tabname('send_chat_states', self.general_jid) and + not self.input.get_text()): + self.send_chat_state('inactive') + self.check_scrolled() + + def on_gain_focus(self): + self.state = 'current' + if (self.text_win.built_lines and self.text_win.built_lines[-1] is None + and not config.get('show_useless_separator')): + self.text_win.remove_line_separator() + curses.curs_set(1) + if self.joined and config.get_by_tabname('send_chat_states', + self.general_jid) and not self.input.get_text(): + self.send_chat_state('active') + + def on_info_win_size_changed(self): + if self.core.information_win_size >= self.height-3: + return + if config.get("hide_user_list"): + text_width = self.width + else: + text_width = (self.width//10)*9 + self.user_win.resize(self.height - 3 - self.core.information_win_size + - Tab.tab_win_height(), + self.width - (self.width // 10) * 9 - 1, + 1, + (self.width // 10) * 9 + 1) + self.v_separator.resize(self.height - 3 - self.core.information_win_size - Tab.tab_win_height(), + 1, 1, 9 * (self.width // 10)) + self.text_win.resize(self.height - 3 - self.core.information_win_size + - Tab.tab_win_height(), + text_width, 1, 0) + self.info_header.resize(1, self.width, + self.height-2-self.core.information_win_size + - Tab.tab_win_height(), + 0) + + def handle_presence(self, presence): + from_nick = presence['from'].resource + from_room = presence['from'].bare + xpath = '{%s}x/{%s}status' % (NS_MUC_USER, NS_MUC_USER) + status_codes = set() + for status_code in presence.findall(xpath): + status_codes.add(status_code.attrib['code']) + + # Check if it's not an error presence. + if presence['type'] == 'error': + return self.core.room_error(presence, from_room) + affiliation = presence['muc']['affiliation'] + show = presence['show'] + status = presence['status'] + role = presence['muc']['role'] + jid = presence['muc']['jid'] + typ = presence['type'] + deterministic = config.get_by_tabname('deterministic_nick_colors', self.name) + color = self.search_for_color(from_nick) + if not self.joined: # user in the room BEFORE us. + # ignore redondant presence message, see bug #1509 + if (from_nick not in [user.nick for user in self.users] + and typ != "unavailable"): + new_user = User(from_nick, affiliation, show, + status, role, jid, deterministic, color) + bisect.insort_left(self.users, new_user) + self.core.events.trigger('muc_join', presence, self) + if '110' in status_codes or self.own_nick == from_nick: + # second part of the condition is a workaround for old + # ejabberd or every gateway in the world that just do + # not send a 110 status code with the presence + self.own_nick = from_nick + self.joined = True + if self.name in self.core.initial_joins: + self.core.initial_joins.remove(self.name) + self._state = 'normal' + elif self != self.core.current_tab(): + self._state = 'joined' + if (self.core.current_tab() is self + and self.core.status.show not in ('xa', 'away')): + self.send_chat_state('active') + new_user.color = get_theme().COLOR_OWN_NICK + + if config.get_by_tabname('display_user_color_in_join_part', + self.general_jid): + color = dump_tuple(new_user.color) + else: + color = 3 + + info_col = dump_tuple(get_theme().COLOR_INFORMATION_TEXT) + warn_col = dump_tuple(get_theme().COLOR_WARNING_TEXT) + spec_col = dump_tuple(get_theme().COLOR_JOIN_CHAR) + + self.add_message( + '\x19%(color_spec)s}%(spec)s\x19%(info_col)s} You ' + '(\x19%(nick_col)s}%(nick)s\x19%(info_col)s}) joined' + ' the chatroom' % + { + 'nick': from_nick, + 'spec': get_theme().CHAR_JOIN, + 'color_spec': spec_col, + 'nick_col': color, + 'info_col': info_col, + }, + typ=2) + if '201' in status_codes: + self.add_message( + '\x19%(info_col)s}Info: The room ' + 'has been created' % + {'info_col': info_col}, + typ=0) + if '170' in status_codes: + self.add_message( + '\x19%(warn_col)s}Warning:\x19%(info_col)s}' + ' This room is publicly logged' % + {'info_col': info_col, + 'warn_col': warn_col}, + typ=0) + if '100' in status_codes: + self.add_message( + '\x19%(warn_col)s}Warning:\x19%(info_col)s}' + ' This room is not anonymous.' % + {'info_col': info_col, + 'warn_col': warn_col}, + typ=0) + if self.core.current_tab() is not self: + self.refresh_tab_win() + self.core.current_tab().input.refresh() + self.core.doupdate() + self.core.enable_private_tabs(self.name) + # Enable the self ping event, to regularly check if we + # are still in the room. + self.enable_self_ping_event() + else: + change_nick = '303' in status_codes + kick = '307' in status_codes and typ == 'unavailable' + ban = '301' in status_codes and typ == 'unavailable' + shutdown = '332' in status_codes and typ == 'unavailable' + non_member = '322' in status_codes and typ == 'unavailable' + user = self.get_user_by_name(from_nick) + # New user + if not user: + self.core.events.trigger('muc_join', presence, self) + self.on_user_join(from_nick, affiliation, show, status, role, + jid, color) + # nick change + elif change_nick: + self.core.events.trigger('muc_nickchange', presence, self) + self.on_user_nick_change(presence, user, from_nick, from_room) + elif ban: + self.core.events.trigger('muc_ban', presence, self) + self.core.on_user_left_private_conversation(from_room, + from_nick, status) + self.on_user_banned(presence, user, from_nick) + # kick + elif kick: + self.core.events.trigger('muc_kick', presence, self) + self.core.on_user_left_private_conversation(from_room, + from_nick, status) + self.on_user_kicked(presence, user, from_nick) + elif shutdown: + self.core.events.trigger('muc_shutdown', presence, self) + self.on_muc_shutdown() + elif non_member: + self.core.events.trigger('muc_shutdown', presence, self) + self.on_non_member_kicked() + # user quit + elif typ == 'unavailable': + self.on_user_leave_groupchat(user, jid, status, + from_nick, from_room) + # status change + else: + self.on_user_change_status(user, from_nick, from_room, + affiliation, role, show, status) + if self.core.current_tab() is self: + self.text_win.refresh() + self.user_win.refresh_if_changed(self.users) + self.info_header.refresh(self, self.text_win) + self.input.refresh() + self.core.doupdate() + + def on_non_member_kicked(self): + """We have been kicked because the MUC is members-only""" + self.add_message( + '\x19%(info_col)s}You have been kicked because you ' + 'are not a member and the room is now members-only.' % { + 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)}, + typ=2) + self.disconnect() + + def on_muc_shutdown(self): + """We have been kicked because the MUC service is shutting down""" + self.add_message( + '\x19%(info_col)s}You have been kicked because the' + ' MUC service is shutting down.' % { + 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)}, + typ=2) + self.disconnect() + + def on_user_join(self, from_nick, affiliation, show, status, role, jid, color): + """ + When a new user joins the groupchat + """ + deterministic = config.get_by_tabname('deterministic_nick_colors', self.name) + user = User(from_nick, affiliation, + show, status, role, jid, deterministic, color) + bisect.insort_left(self.users, user) + hide_exit_join = config.get_by_tabname('hide_exit_join', + self.general_jid) + if hide_exit_join != 0: + if config.get_by_tabname('display_user_color_in_join_part', + self.general_jid): + color = dump_tuple(user.color) + else: + color = 3 + info_col = dump_tuple(get_theme().COLOR_INFORMATION_TEXT) + spec_col = dump_tuple(get_theme().COLOR_JOIN_CHAR) + char_join = get_theme().CHAR_JOIN + if not jid.full: + msg = ('\x19%(color_spec)s}%(spec)s \x19%(color)s}%(nick)s' + '\x19%(info_col)s} joined the chatroom') % { + 'nick': from_nick, 'spec': char_join, + 'color': color, + 'info_col': info_col, + 'color_spec': spec_col, + } + else: + msg = ('\x19%(color_spec)s}%(spec)s \x19%(color)s}%(nick)s' + '\x19%(info_col)s} (\x19%(jid_color)s}%(jid)s\x19' + '%(info_col)s}) joined the chatroom') % { + 'spec': char_join, 'nick': from_nick, + 'color':color, 'jid':jid.full, + 'info_col': info_col, + 'jid_color': dump_tuple(get_theme().COLOR_MUC_JID), + 'color_spec': spec_col, + } + self.add_message(msg, typ=2) + self.core.on_user_rejoined_private_conversation(self.name, from_nick) + + def on_user_nick_change(self, presence, user, from_nick, from_room): + new_nick = presence.find('{%s}x/{%s}item' % (NS_MUC_USER, NS_MUC_USER) + ).attrib['nick'] + if user.nick == self.own_nick: + self.own_nick = new_nick + # also change our nick in all private discussions of this room + self.core.on_muc_own_nickchange(self) + else: + color = config.get_by_tabname(new_nick, 'muc_colors') + if color != '': + deterministic = config.get_by_tabname('deterministic_nick_colors', + self.name) + user.change_color(color, deterministic) + user.change_nick(new_nick) + self.users.remove(user) + bisect.insort_left(self.users, user) + + if config.get_by_tabname('display_user_color_in_join_part', + self.general_jid): + color = dump_tuple(user.color) + else: + color = 3 + info_col = dump_tuple(get_theme().COLOR_INFORMATION_TEXT) + self.add_message('\x19%(color)s}%(old)s\x19%(info_col)s} is' + ' now known as \x19%(color)s}%(new)s' % { + 'old':from_nick, 'new':new_nick, + 'color':color, 'info_col': info_col}, + typ=2) + # rename the private tabs if needed + self.core.rename_private_tabs(self.name, from_nick, new_nick) + + def on_user_banned(self, presence, user, from_nick): + """ + When someone is banned from a muc + """ + self.users.remove(user) + by = presence.find('{%s}x/{%s}item/{%s}actor' % + (NS_MUC_USER, NS_MUC_USER, NS_MUC_USER)) + reason = presence.find('{%s}x/{%s}item/{%s}reason' % + (NS_MUC_USER, NS_MUC_USER, NS_MUC_USER)) + by = by.attrib['jid'] if by is not None else None + + info_col = dump_tuple(get_theme().COLOR_INFORMATION_TEXT) + char_kick = get_theme().CHAR_KICK + + if from_nick == self.own_nick: # we are banned + if by: + kick_msg = ('\x191}%(spec)s \x193}You\x19%(info_col)s}' + ' have been banned by \x194}%(by)s') % { + 'spec': char_kick, 'by': by, + 'info_col': info_col} + else: + kick_msg = ('\x191}%(spec)s \x193}You\x19' + '%(info_col)s} have been banned.') % { + 'spec': char_kick, 'info_col': info_col} + self.core.disable_private_tabs(self.name, reason=kick_msg) + self.disconnect() + self.refresh_tab_win() + self.core.current_tab().input.refresh() + self.core.doupdate() + if config.get_by_tabname('autorejoin', self.general_jid): + delay = config.get_by_tabname('autorejoin_delay', + self.general_jid) + delay = common.parse_str_to_secs(delay) + if delay <= 0: + muc.join_groupchat(self.core, self.name, self.own_nick) + else: + self.core.add_timed_event(timed_events.DelayedEvent( + delay, + muc.join_groupchat, + self.core, + self.name, + self.own_nick)) + + else: + if config.get_by_tabname('display_user_color_in_join_part', + self.general_jid): + color = dump_tuple(user.color) + else: + color = 3 + + if by: + kick_msg = ('\x191}%(spec)s \x19%(color)s}' + '%(nick)s\x19%(info_col)s} ' + 'has been banned by \x194}%(by)s') % { + 'spec': char_kick, 'nick': from_nick, + 'color': color, 'by': by, + 'info_col': info_col} + else: + kick_msg = ('\x191}%(spec)s \x19%(color)s}%(nick)s' + '\x19%(info_col)s} has been banned') % { + 'spec': char_kick, 'nick': from_nick, + 'color': color, 'info_col': info_col} + if reason is not None and reason.text: + kick_msg += ('\x19%(info_col)s} Reason: \x196}' + '%(reason)s\x19%(info_col)s}') % { + 'reason': reason.text, 'info_col': info_col} + self.add_message(kick_msg, typ=2) + + def on_user_kicked(self, presence, user, from_nick): + """ + When someone is kicked from a muc + """ + self.users.remove(user) + actor_elem = presence.find('{%s}x/{%s}item/{%s}actor' % + (NS_MUC_USER, NS_MUC_USER, NS_MUC_USER)) + reason = presence.find('{%s}x/{%s}item/{%s}reason' % + (NS_MUC_USER, NS_MUC_USER, NS_MUC_USER)) + by = None + info_col = dump_tuple(get_theme().COLOR_INFORMATION_TEXT) + char_kick = get_theme().CHAR_KICK + if actor_elem is not None: + by = actor_elem.get('nick') or actor_elem.get('jid') + if from_nick == self.own_nick: # we are kicked + if by: + kick_msg = ('\x191}%(spec)s \x193}You\x19' + '%(info_col)s} have been kicked' + ' by \x193}%(by)s') % { + 'spec': char_kick, 'by': by, + 'info_col': info_col} + else: + kick_msg = ('\x191}%(spec)s \x193}You\x19%(info_col)s}' + ' have been kicked.') % { + 'spec': char_kick, + 'info_col': info_col} + self.core.disable_private_tabs(self.name, reason=kick_msg) + self.disconnect() + self.refresh_tab_win() + self.core.current_tab().input.refresh() + self.core.doupdate() + # try to auto-rejoin + if config.get_by_tabname('autorejoin', self.general_jid): + delay = config.get_by_tabname('autorejoin_delay', + self.general_jid) + delay = common.parse_str_to_secs(delay) + if delay <= 0: + muc.join_groupchat(self.core, self.name, self.own_nick) + else: + self.core.add_timed_event(timed_events.DelayedEvent( + delay, + muc.join_groupchat, + self.core, + self.name, + self.own_nick)) + else: + if config.get_by_tabname('display_user_color_in_join_part', + self.general_jid): + color = dump_tuple(user.color) + else: + color = 3 + if by: + kick_msg = ('\x191}%(spec)s \x19%(color)s}%(nick)s' + '\x19%(info_col)s} has been kicked by ' + '\x193}%(by)s') % { + 'spec': char_kick, 'nick':from_nick, + 'color':color, 'by':by, 'info_col': info_col} + else: + kick_msg = ('\x191}%(spec)s \x19%(color)s}%(nick)s' + '\x19%(info_col)s} has been kicked') % { + 'spec': char_kick, 'nick': from_nick, + 'color':color, 'info_col': info_col} + if reason is not None and reason.text: + kick_msg += ('\x19%(info_col)s} Reason: \x196}' + '%(reason)s') % { + 'reason': reason.text, 'info_col': info_col} + self.add_message(kick_msg, typ=2) + + def on_user_leave_groupchat(self, user, jid, status, from_nick, from_room): + """ + When an user leaves a groupchat + """ + self.users.remove(user) + if self.own_nick == user.nick: + # We are now out of the room. + # Happens with some buggy (? not sure) servers + self.disconnect() + self.core.disable_private_tabs(from_room) + self.refresh_tab_win() + + hide_exit_join = config.get_by_tabname('hide_exit_join', + self.general_jid) + + if hide_exit_join <= -1 or user.has_talked_since(hide_exit_join): + if config.get_by_tabname('display_user_color_in_join_part', + self.general_jid): + color = dump_tuple(user.color) + else: + color = 3 + info_col = dump_tuple(get_theme().COLOR_INFORMATION_TEXT) + spec_col = dump_tuple(get_theme().COLOR_QUIT_CHAR) + + if not jid.full: + leave_msg = ('\x19%(color_spec)s}%(spec)s \x19%(color)s}' + '%(nick)s\x19%(info_col)s} has left the ' + 'chatroom') % { + 'nick':from_nick, 'color':color, + 'spec':get_theme().CHAR_QUIT, + 'info_col': info_col, + 'color_spec': spec_col} + else: + jid_col = dump_tuple(get_theme().COLOR_MUC_JID) + leave_msg = ('\x19%(color_spec)s}%(spec)s \x19%(color)s}' + '%(nick)s\x19%(info_col)s} (\x19%(jid_col)s}' + '%(jid)s\x19%(info_col)s}) has left the ' + 'chatroom') % { + 'spec':get_theme().CHAR_QUIT, + 'nick':from_nick, 'color':color, + 'jid':jid.full, 'info_col': info_col, + 'color_spec': spec_col, + 'jid_col': jid_col} + if status: + leave_msg += ' (\x19o%s\x19%s})' % (status, info_col) + self.add_message(leave_msg, typ=2) + self.core.on_user_left_private_conversation(from_room, from_nick, + status) + + def on_user_change_status( + self, user, from_nick, from_room, affiliation, role, show, status): + """ + When an user changes her status + """ + # build the message + display_message = False # flag to know if something significant enough + # to be displayed has changed + if config.get_by_tabname('display_user_color_in_join_part', + self.general_jid): + color = dump_tuple(user.color) + else: + color = 3 + if from_nick == self.own_nick: + msg = '\x19%(color)s}You\x19%(info_col)s} changed: ' % { + 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT), + 'color': color} + else: + msg = '\x19%(color)s}%(nick)s\x19%(info_col)s} changed: ' % { + 'nick': from_nick, 'color': color, + 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)} + if affiliation != user.affiliation: + msg += 'affiliation: %s, ' % affiliation + display_message = True + if role != user.role: + msg += 'role: %s, ' % role + display_message = True + if show != user.show and show in SHOW_NAME: + msg += 'show: %s, ' % SHOW_NAME[show] + display_message = True + if status != user.status: + # if the user sets his status to nothing + if status: + msg += 'status: %s, ' % status + display_message = True + elif show in SHOW_NAME and show == user.show: + msg += 'show: %s, ' % SHOW_NAME[show] + display_message = True + if not display_message: + return + msg = msg[:-2] # remove the last ", " + hide_status_change = config.get_by_tabname('hide_status_change', + self.general_jid) + if hide_status_change < -1: + hide_status_change = -1 + if ((hide_status_change == -1 or \ + user.has_talked_since(hide_status_change) or\ + user.nick == self.own_nick)\ + and\ + (affiliation != user.affiliation or\ + role != user.role or\ + show != user.show or\ + status != user.status))\ + or\ + (affiliation != user.affiliation or\ + role != user.role): + # display the message in the room + self._text_buffer.add_message(msg) + self.core.on_user_changed_status_in_private('%s/%s' % + (from_room, from_nick), + msg) + self.users.remove(user) + # finally, effectively change the user status + user.update(affiliation, show, status, role) + bisect.insort_left(self.users, user) + + def disconnect(self): + """ + Set the state of the room as not joined, so + we can know if we can join it, send messages to it, etc + """ + self.users = [] + if self is not self.core.current_tab(): + self.state = 'disconnected' + self.joined = False + self.disable_self_ping_event() + + def get_single_line_topic(self): + """ + Return the topic as a single-line string (for the window header) + """ + return self.topic.replace('\n', '|') + + def log_message(self, txt, nickname, time=None, typ=1): + """ + Log the messages in the archives, if it needs + to be + """ + if time is None and self.joined: # don't log the history messages + if not logger.log_message(self.name, nickname, txt, typ=typ): + self.core.information('Unable to write in the log file', + 'Error') + + def do_highlight(self, txt, time, nickname): + """ + Set the tab color and returns the nick color + """ + highlighted = False + if not time and nickname and nickname != self.own_nick and self.joined: + + if re.search(r'\b' + self.own_nick.lower() + r'\b', txt.lower()): + if self.state != 'current': + self.state = 'highlight' + highlighted = True + else: + highlight_words = config.get_by_tabname('highlight_on', + self.general_jid) + highlight_words = highlight_words.split(':') + for word in highlight_words: + if word and word.lower() in txt.lower(): + if self.state != 'current': + self.state = 'highlight' + highlighted = True + break + if highlighted: + beep_on = config.get('beep_on').split() + if 'highlight' in beep_on and 'message' not in beep_on: + if not config.get_by_tabname('disable_beep', self.name): + curses.beep() + return highlighted + + def get_user_by_name(self, nick): + """ + Gets the user associated with the given nick, or None if not found + """ + for user in self.users: + if user.nick == nick: + return user + return None + + def add_message(self, txt, time=None, nickname=None, **kwargs): + """ + Note that user can be None even if nickname is not None. It happens + when we receive an history message said by someone who is not + in the room anymore + Return True if the message highlighted us. False otherwise. + """ + + # reset self-ping interval + if self.self_ping_event: + self.enable_self_ping_event() + + self.log_message(txt, nickname, time=time, typ=kwargs.get('typ', 1)) + args = dict() + for key, value in kwargs.items(): + if key not in ('typ', 'forced_user'): + args[key] = value + if nickname is not None: + user = self.get_user_by_name(nickname) + else: + user = None + + if user: + user.set_last_talked(datetime.now()) + args['user'] = user + if not user and kwargs.get('forced_user'): + args['user'] = kwargs['forced_user'] + + if (not time and nickname and nickname != self.own_nick + and self.state != 'current'): + if (self.state != 'highlight' and + config.get_by_tabname('notify_messages', self.name)): + self.state = 'message' + if time and not txt.startswith('/me'): + txt = '\x19%(info_col)s}%(txt)s' % { + 'txt': txt, + 'info_col': dump_tuple(get_theme().COLOR_LOG_MSG)} + elif not nickname: + txt = '\x19%(info_col)s}%(txt)s' % { + 'txt': txt, + 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)} + elif not kwargs.get('highlight'): # TODO + args['highlight'] = self.do_highlight(txt, time, nickname) + time = time or datetime.now() + self._text_buffer.add_message(txt, time, nickname, **args) + return args.get('highlight', False) + + def modify_message(self, txt, old_id, new_id, + time=None, nickname=None, user=None, jid=None): + self.log_message(txt, nickname, time=time, typ=1) + highlight = self.do_highlight(txt, time, nickname) + message = self._text_buffer.modify_message(txt, old_id, new_id, + highlight=highlight, + time=time, user=user, + jid=jid) + if message: + self.text_win.modify_message(old_id, message) + return highlight + return False + + def matching_names(self): + return [(1, safeJID(self.name).user), (3, self.name)] + + def enable_self_ping_event(self): + delay = config.get_by_tabname("self_ping_delay", self.general_jid, default=0) + if delay <= 0: # use 0 or some negative value to disable it + return + self.disable_self_ping_event() + self.self_ping_event = timed_events.DelayedEvent(delay, self.send_self_ping) + self.core.add_timed_event(self.self_ping_event) + + def disable_self_ping_event(self): + if self.self_ping_event is not None: + self.core.remove_timed_event(self.self_ping_event) + self.self_ping_event = None + + def send_self_ping(self): + to = self.name + "/" + self.own_nick + self.core.xmpp.plugin['xep_0199'].send_ping(jid=to, + callback=self.on_self_ping_result, + timeout_callback=self.on_self_ping_failed, + timeout=60) + + def on_self_ping_result(self, iq): + if iq["type"] == "error": + self.command_cycle(iq["error"]["text"] or "not in this room") + self.core.refresh_window() + else: # Re-send a self-ping in a few seconds + self.enable_self_ping_event() + + def search_for_color(self, nick): + """ + Search for the color of a nick in the config file. + Also, look at the colors of its possible aliases if nick_color_aliases + is set. + """ + color = config.get_by_tabname(nick, 'muc_colors') + if color != '': + return color + nick_color_aliases = config.get_by_tabname('nick_color_aliases', self.name) + if nick_color_aliases: + nick_alias = re.sub('^_*(.*?)_*$', '\\1', nick) + color = config.get_by_tabname(nick_alias, 'muc_colors') + return color + + def on_self_ping_failed(self, iq): + self.command_cycle("the MUC server is not responding") + self.core.refresh_window() diff --git a/poezio/tabs/privatetab.py b/poezio/tabs/privatetab.py new file mode 100644 index 00000000..a715a922 --- /dev/null +++ b/poezio/tabs/privatetab.py @@ -0,0 +1,362 @@ +""" +Module for the PrivateTab + +A PrivateTab is a private conversation opened with someone from a MUC +(see muctab.py). The conversation happens with both JID being relative +to the MUC (room@server/nick1 and room@server/nick2). + +This tab references his parent room, and is modified to keep track of +both participant’s nicks. It also has slightly different features than +the ConversationTab (such as tab-completion on nicks from the room). + +""" +import logging +log = logging.getLogger(__name__) + +import curses + +from . import OneToOneTab, MucTab, Tab + +import fixes +import windows +import xhtml +from common import safeJID +from config import config +from decorators import refresh_wrapper +from logger import logger +from theming import get_theme, dump_tuple +from decorators import command_args_parser + +class PrivateTab(OneToOneTab): + """ + The tab containg a private conversation (someone from a MUC) + """ + message_type = 'chat' + plugin_commands = {} + additional_informations = {} + plugin_keys = {} + def __init__(self, name, nick): + OneToOneTab.__init__(self, name) + self.own_nick = nick + self.name = name + self.text_win = windows.TextWin() + self._text_buffer.add_window(self.text_win) + self.info_header = windows.PrivateInfoWin() + self.input = windows.MessageInput() + # keys + self.key_func['^I'] = self.completion + # commands + self.register_command('info', self.command_info, + desc='Display some information about the user in the MUC: its/his/her role, affiliation, status and status message.', + shortdesc='Info about the user.') + self.register_command('unquery', self.command_unquery, + shortdesc='Close the tab.') + self.register_command('close', self.command_unquery, + shortdesc='Close the tab.') + self.register_command('version', self.command_version, + desc='Get the software version of the current interlocutor (usually its XMPP client and Operating System).', + shortdesc='Get the software version of a jid.') + self.resize() + self.parent_muc = self.core.get_tab_by_name(safeJID(name).bare, MucTab) + self.on = True + self.update_commands() + self.update_keys() + + @property + def general_jid(self): + return self.name + + def get_dest_jid(self): + return self.name + + @property + def nick(self): + return self.get_nick() + + @staticmethod + def add_information_element(plugin_name, callback): + """ + Lets a plugin add its own information to the PrivateInfoWin + """ + PrivateTab.additional_informations[plugin_name] = callback + + @staticmethod + def remove_information_element(plugin_name): + del PrivateTab.additional_informations[plugin_name] + + def load_logs(self, log_nb): + logs = logger.get_logs(safeJID(self.name).full.replace('/', '\\'), log_nb) + return logs + + def log_message(self, txt, nickname, time=None, typ=1): + """ + Log the messages in the archives. + """ + if not logger.log_message(self.name, nickname, txt, date=time, typ=typ): + self.core.information('Unable to write in the log file', 'Error') + + def on_close(self): + self.parent_muc.privates.remove(self) + + def completion(self): + """ + Called when Tab is pressed, complete the nickname in the input + """ + if self.complete_commands(self.input): + return + + # If we are not completing a command or a command's argument, complete a nick + compare_users = lambda x: x.last_talked + word_list = [user.nick for user in sorted(self.parent_muc.users, key=compare_users, reverse=True)\ + if user.nick != self.own_nick] + after = config.get('after_completion') + ' ' + input_pos = self.input.pos + if ' ' not in self.input.get_text()[:input_pos] or (self.input.last_completion and\ + self.input.get_text()[:input_pos] == self.input.last_completion + after): + add_after = after + else: + add_after = '' + self.input.auto_completion(word_list, add_after, quotify=False) + empty_after = self.input.get_text() == '' or (self.input.get_text().startswith('/') and not self.input.get_text().startswith('//')) + self.send_composing_chat_state(empty_after) + + @command_args_parser.raw + def command_say(self, line, attention=False, correct=False): + if not self.on: + return + msg = self.core.xmpp.make_message(self.name) + msg['type'] = 'chat' + msg['body'] = line + # trigger the event BEFORE looking for colors. + # This lets a plugin insert \x19xxx} colors, that will + # be converted in xhtml. + self.core.events.trigger('private_say', msg, self) + if not msg['body']: + self.cancel_paused_delay() + self.text_win.refresh() + self.input.refresh() + return + user = self.parent_muc.get_user_by_name(self.own_nick) + replaced = False + if correct or msg['replace']['id']: + msg['replace']['id'] = self.last_sent_message['id'] + if config.get_by_tabname('group_corrections', self.name): + try: + self.modify_message(msg['body'], self.last_sent_message['id'], msg['id'], + user=user, jid=self.core.xmpp.boundjid, nickname=self.own_nick) + replaced = True + except: + log.error('Unable to correct a message', exc_info=True) + else: + del msg['replace'] + + if msg['body'].find('\x19') != -1: + msg.enable('html') + msg['html']['body'] = xhtml.poezio_colors_to_html(msg['body']) + msg['body'] = xhtml.clean_text(msg['body']) + if (config.get_by_tabname('send_chat_states', self.general_jid) and + self.remote_wants_chatstates is not False): + needed = 'inactive' if self.inactive else 'active' + msg['chat_state'] = needed + if attention and self.remote_supports_attention: + msg['attention'] = True + self.core.events.trigger('private_say_after', msg, self) + if not msg['body']: + self.cancel_paused_delay() + self.text_win.refresh() + self.input.refresh() + return + if not replaced: + self.add_message(msg['body'], + nickname=self.own_nick or self.core.own_nick, + forced_user=user, + nick_color=get_theme().COLOR_OWN_NICK, + identifier=msg['id'], + jid=self.core.xmpp.boundjid, + typ=1) + + self.last_sent_message = msg + if self.remote_supports_receipts: + msg._add_receipt = True + msg.send() + self.cancel_paused_delay() + self.text_win.refresh() + self.input.refresh() + + @command_args_parser.ignored + def command_unquery(self): + """ + /unquery + """ + self.core.close_tab() + + @command_args_parser.quoted(0, 1) + def command_version(self, args): + """ + /version + """ + def callback(res): + if not res: + return self.core.information('Could not get the software version from %s' % (jid,), 'Warning') + version = '%s is running %s version %s on %s' % (jid, + res.get('name') or 'an unknown software', + res.get('version') or 'unknown', + res.get('os') or 'an unknown platform') + self.core.information(version, 'Info') + if args: + return self.core.command_version(args[0]) + jid = safeJID(self.name) + fixes.get_version(self.core.xmpp, jid, + callback=callback) + + @command_args_parser.quoted(0, 1) + def command_info(self, arg): + """ + /info + """ + if arg and arg[0]: + self.parent_muc.command_info(arg[0]) + else: + user = safeJID(self.name).resource + self.parent_muc.command_info(user) + + def resize(self): + self.need_resize = False + + if self.size.tab_degrade_y: + info_win_height = 0 + tab_win_height = 0 + else: + info_win_height = self.core.information_win_size + tab_win_height = Tab.tab_win_height() + + self.text_win.resize(self.height - 2 - info_win_height - tab_win_height, + self.width, 0, 0) + self.text_win.rebuild_everything(self._text_buffer) + self.info_header.resize(1, self.width, + self.height - 2 - info_win_height + - tab_win_height, + 0) + self.input.resize(1, self.width, self.height-1, 0) + + def refresh(self): + if self.need_resize: + self.resize() + log.debug(' TAB Refresh: %s', self.__class__.__name__) + display_info_win = not self.size.tab_degrade_y + + self.text_win.refresh() + self.info_header.refresh(self.name, self.text_win, self.chatstate, + PrivateTab.additional_informations) + if display_info_win: + self.info_win.refresh() + + self.refresh_tab_win() + self.input.refresh() + + def refresh_info_header(self): + self.info_header.refresh(self.name, self.text_win, self.chatstate, PrivateTab.additional_informations) + self.input.refresh() + + def get_nick(self): + return safeJID(self.name).resource + + def on_input(self, key, raw): + if not raw and key in self.key_func: + self.key_func[key]() + return False + self.input.do_command(key, raw=raw) + if not self.on: + return False + empty_after = self.input.get_text() == '' or (self.input.get_text().startswith('/') and not self.input.get_text().startswith('//')) + tab = self.core.get_tab_by_name(safeJID(self.name).bare, MucTab) + if tab and tab.joined: + self.send_composing_chat_state(empty_after) + return False + + def on_lose_focus(self): + if self.input.text: + self.state = 'nonempty' + else: + self.state = 'normal' + + self.text_win.remove_line_separator() + self.text_win.add_line_separator(self._text_buffer) + tab = self.core.get_tab_by_name(safeJID(self.name).bare, MucTab) + if tab and tab.joined and config.get_by_tabname('send_chat_states', + self.general_jid) and not self.input.get_text() and self.on: + self.send_chat_state('inactive') + self.check_scrolled() + + def on_gain_focus(self): + self.state = 'current' + curses.curs_set(1) + tab = self.core.get_tab_by_name(safeJID(self.name).bare, MucTab) + if tab and tab.joined and config.get_by_tabname('send_chat_states', + self.general_jid,) and not self.input.get_text() and self.on: + self.send_chat_state('active') + + def on_info_win_size_changed(self): + if self.core.information_win_size >= self.height-3: + return + self.text_win.resize(self.height-2-self.core.information_win_size - Tab.tab_win_height(), self.width, 0, 0) + self.info_header.resize(1, self.width, self.height-2-self.core.information_win_size - Tab.tab_win_height(), 0) + + def get_text_window(self): + return self.text_win + + @refresh_wrapper.conditional + def rename_user(self, old_nick, new_nick): + """ + The user changed her nick in the corresponding muc: update the tab’s name and + display a message. + """ + self.add_message('\x193}%(old)s\x19%(info_col)s} is now known as \x193}%(new)s' % {'old':old_nick, 'new':new_nick, 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)}, typ=2) + new_jid = safeJID(self.name).bare+'/'+new_nick + self.name = new_jid + return self.core.current_tab() is self + + @refresh_wrapper.conditional + def user_left(self, status_message, from_nick): + """ + The user left the associated MUC + """ + self.deactivate() + if not status_message: + self.add_message('\x191}%(spec)s \x193}%(nick)s\x19%(info_col)s} has left the room' % {'nick':from_nick, 'spec':get_theme().CHAR_QUIT, 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)}, typ=2) + else: + self.add_message('\x191}%(spec)s \x193}%(nick)s\x19%(info_col)s} has left the room (%(status)s)"' % {'nick':from_nick, 'spec':get_theme().CHAR_QUIT, 'status': status_message, 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)}, typ=2) + return self.core.current_tab() is self + + @refresh_wrapper.conditional + def user_rejoined(self, nick): + """ + The user (or at least someone with the same nick) came back in the MUC + """ + self.activate() + self.check_features() + tab = self.core.get_tab_by_name(safeJID(self.name).bare, MucTab) + color = 3 + if tab and config.get_by_tabname('display_user_color_in_join_part', + self.general_jid): + user = tab.get_user_by_name(nick) + if user: + color = dump_tuple(user.color) + self.add_message('\x194}%(spec)s \x19%(color)s}%(nick)s\x19%(info_col)s} joined the room' % {'nick':nick, 'color': color, 'spec':get_theme().CHAR_JOIN, 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)}, typ=2) + return self.core.current_tab() is self + + def activate(self, reason=None): + self.on = True + if reason: + self.add_message(txt=reason, typ=2) + + def deactivate(self, reason=None): + self.on = False + self.remote_wants_chatstates = None + if reason: + self.add_message(txt=reason, typ=2) + + def matching_names(self): + return [(3, safeJID(self.name).resource), (4, self.name)] + + diff --git a/poezio/tabs/rostertab.py b/poezio/tabs/rostertab.py new file mode 100644 index 00000000..a5c22304 --- /dev/null +++ b/poezio/tabs/rostertab.py @@ -0,0 +1,1280 @@ +""" +The RosterInfoTab is the tab showing roster info, the list of contacts, +half of it is dedicated to showing the information buffer, and a small +rectangle shows the current contact info. + +This module also includes functions to match users in the roster. +""" +import logging +log = logging.getLogger(__name__) + +import base64 +import curses +import difflib +import os +import ssl +from os import getenv, path +from functools import partial + +from . import Tab + +import common +import windows +from common import safeJID +from config import config +from contact import Contact, Resource +from decorators import refresh_wrapper +from roster import RosterGroup, roster +from theming import get_theme, dump_tuple +from decorators import command_args_parser + +class RosterInfoTab(Tab): + """ + A tab, splitted in two, containing the roster and infos + """ + plugin_commands = {} + plugin_keys = {} + def __init__(self): + Tab.__init__(self) + self.name = "Roster" + self.v_separator = windows.VerticalSeparator() + self.information_win = windows.TextWin() + self.core.information_buffer.add_window(self.information_win) + self.roster_win = windows.RosterWin() + self.contact_info_win = windows.ContactInfoWin() + self.default_help_message = windows.HelpText("Enter commands with “/”. “o”: toggle offline show") + self.input = self.default_help_message + self.state = 'normal' + self.key_func['^I'] = self.completion + self.key_func["/"] = self.on_slash + # disable most of the roster features when in anonymous mode + if not self.core.xmpp.anon: + self.key_func[' '] = self.on_space + self.key_func["KEY_UP"] = self.move_cursor_up + self.key_func["KEY_DOWN"] = self.move_cursor_down + self.key_func["M-u"] = self.move_cursor_to_next_contact + self.key_func["M-y"] = self.move_cursor_to_prev_contact + self.key_func["M-U"] = self.move_cursor_to_next_group + self.key_func["M-Y"] = self.move_cursor_to_prev_group + self.key_func["M-[1;5B"] = self.move_cursor_to_next_group + self.key_func["M-[1;5A"] = self.move_cursor_to_prev_group + self.key_func["l"] = self.command_last_activity + self.key_func["o"] = self.toggle_offline_show + self.key_func["v"] = self.get_contact_version + self.key_func["i"] = self.show_contact_info + self.key_func["s"] = self.start_search + self.key_func["S"] = self.start_search_slow + self.key_func["n"] = self.change_contact_name + self.register_command('deny', self.command_deny, + usage='[jid]', + desc='Deny your presence to the provided JID (or the ' + 'selected contact in your roster), who is asking' + 'you to be in his/here roster.', + shortdesc='Deny an user your presence.', + completion=self.completion_deny) + self.register_command('accept', self.command_accept, + usage='[jid]', + desc='Allow the provided JID (or the selected contact ' + 'in your roster), to see your presence.', + shortdesc='Allow an user your presence.', + completion=self.completion_deny) + self.register_command('add', self.command_add, + usage='<jid>', + desc='Add the specified JID to your roster, ask him to' + ' allow you to see his presence, and allow him to' + ' see your presence.', + shortdesc='Add an user to your roster.') + self.register_command('name', self.command_name, + usage='<jid> [name]', + shortdesc='Set the given JID\'s name.', + completion=self.completion_name) + self.register_command('groupadd', self.command_groupadd, + usage='<jid> <group>', + desc='Add the given JID to the given group.', + shortdesc='Add an user to a group', + completion=self.completion_groupadd) + self.register_command('groupmove', self.command_groupmove, + usage='<jid> <old group> <new group>', + desc='Move the given JID from the old group to the new group.', + shortdesc='Move an user to another group.', + completion=self.completion_groupmove) + self.register_command('groupremove', self.command_groupremove, + usage='<jid> <group>', + desc='Remove the given JID from the given group.', + shortdesc='Remove an user from a group.', + completion=self.completion_groupremove) + self.register_command('remove', self.command_remove, + usage='[jid]', + desc='Remove the specified JID from your roster. This ' + 'will unsubscribe you from its presence, cancel ' + 'its subscription to yours, and remove the item ' + 'from your roster.', + shortdesc='Remove an user from your roster.', + completion=self.completion_remove) + self.register_command('export', self.command_export, + usage='[/path/to/file]', + desc='Export your contacts into /path/to/file if ' + 'specified, or $HOME/poezio_contacts if not.', + shortdesc='Export your roster to a file.', + completion=partial(self.completion_file, 1)) + self.register_command('import', self.command_import, + usage='[/path/to/file]', + desc='Import your contacts from /path/to/file if ' + 'specified, or $HOME/poezio_contacts if not.', + shortdesc='Import your roster from a file.', + completion=partial(self.completion_file, 1)) + self.register_command('password', self.command_password, + usage='<password>', + shortdesc='Change your password') + + self.register_command('reconnect', self.command_reconnect, + desc='Disconnect from the remote server if you are ' + 'currently connected and then connect to it again.', + shortdesc='Disconnect and reconnect to the server.') + self.register_command('disconnect', self.command_disconnect, + desc='Disconnect from the remote server.', + shortdesc='Disconnect from the server.') + self.register_command('clear', self.command_clear, + shortdesc='Clear the info buffer.') + self.register_command('last_activity', self.command_last_activity, + usage='<jid>', + desc='Informs you of the last activity of a JID.', + shortdesc='Get the activity of someone.', + completion=self.core.completion_last_activity) + + self.resize() + self.update_commands() + self.update_keys() + + def check_blocking(self, features): + if 'urn:xmpp:blocking' in features and not self.core.xmpp.anon: + self.register_command('block', self.command_block, + usage='[jid]', + shortdesc='Prevent a JID from talking to you.', + completion=self.completion_block) + self.register_command('unblock', self.command_unblock, + usage='[jid]', + shortdesc='Allow a JID to talk to you.', + completion=self.completion_unblock) + self.register_command('list_blocks', self.command_list_blocks, + shortdesc='Show the blocked contacts.') + self.core.xmpp.del_event_handler('session_start', self.check_blocking) + self.core.xmpp.add_event_handler('blocked_message', self.on_blocked_message) + + def check_saslexternal(self, features): + if 'urn:xmpp:saslcert:1' in features and not self.core.xmpp.anon: + self.register_command('certs', self.command_certs, + desc='List the fingerprints of certificates' + ' which can connect to your account.', + shortdesc='List allowed client certs.') + self.register_command('cert_add', self.command_cert_add, + desc='Add a client certificate to the authorized ones. ' + 'It must have an unique name and be contained in ' + 'a PEM file. [management] is a boolean indicating' + ' if a client connected using this certificate can' + ' manage the certificates itself.', + shortdesc='Add a client certificate.', + usage='<name> <certificate path> [management]', + completion=self.completion_cert_add) + self.register_command('cert_disable', self.command_cert_disable, + desc='Remove a certificate from the list ' + 'of allowed ones. Clients currently ' + 'using this certificate will not be ' + 'forcefully disconnected.', + shortdesc='Disable a certificate', + usage='<name>') + self.register_command('cert_revoke', self.command_cert_revoke, + desc='Remove a certificate from the list ' + 'of allowed ones. Clients currently ' + 'using this certificate will be ' + 'forcefully disconnected.', + shortdesc='Revoke a certificate', + usage='<name>') + self.register_command('cert_fetch', self.command_cert_fetch, + desc='Retrieve a certificate with its ' + 'name. It will be stored in <path>.', + shortdesc='Fetch a certificate', + usage='<name> <path>', + completion=self.completion_cert_fetch) + + @property + def selected_row(self): + return self.roster_win.get_selected_row() + + @command_args_parser.ignored + def command_certs(self): + """ + /certs + """ + def cb(iq): + if iq['type'] == 'error': + self.core.information('Unable to retrieve the certificate list.', + 'Error') + return + certs = [] + for item in iq['sasl_certs']['items']: + users = '\n'.join(item['users']) + certs.append((item['name'], users)) + + if not certs: + return self.core.information('No certificates found', 'Info') + msg = 'Certificates:\n' + msg += '\n'.join(((' %s%s' % (item[0] + (': ' if item[1] else ''), item[1])) for item in certs)) + self.core.information(msg, 'Info') + + self.core.xmpp.plugin['xep_0257'].get_certs(callback=cb, timeout=3) + + @command_args_parser.quoted(2, 1) + def command_cert_add(self, args): + """ + /cert_add <name> <certfile> [cert-management] + """ + if not args or len(args) < 2: + return self.core.command_help('cert_add') + def cb(iq): + if iq['type'] == 'error': + self.core.information('Unable to add the certificate.', 'Error') + else: + self.core.information('Certificate added.', 'Info') + + name = args[0] + + try: + with open(args[1]) as fd: + crt = fd.read() + crt = crt.replace(ssl.PEM_FOOTER, '').replace(ssl.PEM_HEADER, '').replace(' ', '').replace('\n', '') + except Exception as e: + self.core.information('Unable to read the certificate: %s' % e, 'Error') + return + + if len(args) > 2: + management = args[2] + if management: + management = management.lower() + if management not in ('false', '0'): + management = True + else: + management = False + else: + management = False + else: + management = True + + self.core.xmpp.plugin['xep_0257'].add_cert(name, crt, callback=cb, + allow_management=management) + + def completion_cert_add(self, the_input): + """ + completion for /cert_add <name> <path> [management] + """ + text = the_input.get_text() + args = common.shell_split(text) + n = the_input.get_argument_position() + log.debug('%s %s %s', the_input.text, n, the_input.pos) + if n == 1: + return + elif n == 2: + return self.completion_file(2, the_input) + elif n == 3: + return the_input.new_completion(['true', 'false'], n) + + @command_args_parser.quoted(1) + def command_cert_disable(self, args): + """ + /cert_disable <name> + """ + if not args: + return self.core.command_help('cert_disable') + def cb(iq): + if iq['type'] == 'error': + self.core.information('Unable to disable the certificate.', 'Error') + else: + self.core.information('Certificate disabled.', 'Info') + + name = args[0] + + self.core.xmpp.plugin['xep_0257'].disable_cert(name, callback=cb) + + @command_args_parser.quoted(1) + def command_cert_revoke(self, args): + """ + /cert_revoke <name> + """ + if not args: + return self.core.command_help('cert_revoke') + def cb(iq): + if iq['type'] == 'error': + self.core.information('Unable to revoke the certificate.', 'Error') + else: + self.core.information('Certificate revoked.', 'Info') + + name = args[0] + + self.core.xmpp.plugin['xep_0257'].revoke_cert(name, callback=cb) + + + @command_args_parser.quoted(2) + def command_cert_fetch(self, args): + """ + /cert_fetch <name> <path> + """ + if not args or len(args) < 2: + return self.core.command_help('cert_fetch') + def cb(iq): + if iq['type'] == 'error': + self.core.information('Unable to fetch the certificate.', + 'Error') + return + + cert = None + for item in iq['sasl_certs']['items']: + if item['name'] == name: + cert = base64.b64decode(item['x509cert']) + break + + if not cert: + return self.core.information('Certificate not found.', 'Info') + + cert = ssl.DER_cert_to_PEM_cert(cert) + with open(path, 'w') as fd: + fd.write(cert) + + self.core.information('File stored at %s' % path, 'Info') + + name = args[0] + path = args[1] + + self.core.xmpp.plugin['xep_0257'].get_certs(callback=cb) + + def completion_cert_fetch(self, the_input): + """ + completion for /cert_fetch <name> <path> + """ + text = the_input.get_text() + args = common.shell_split(text) + n = the_input.get_argument_position() + log.debug('%s %s %s', the_input.text, n, the_input.pos) + if n == 1: + return + elif n == 2: + return self.completion_file(2, the_input) + + def on_blocked_message(self, message): + """ + When we try to send a message to a blocked contact + """ + tab = self.core.get_conversation_by_jid(message['from'], False) + if not tab: + log.debug('Received message from nonexistent tab: %s', message['from']) + message = '\x19%(info_col)s}Cannot send message to %(jid)s: contact blocked' % { + 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT), + 'jid': message['from'], + } + tab.add_message(message) + + @command_args_parser.quoted(0, 1) + def command_block(self, args): + """ + /block [jid] + """ + def callback(iq): + if iq['type'] == 'error': + return self.core.information('Could not block the contact.', 'Error') + elif iq['type'] == 'result': + return self.core.information('Contact blocked.', 'Info') + + item = self.roster_win.selected_row + if args: + jid = safeJID(args[0]) + elif isinstance(item, Contact): + jid = item.bare_jid + elif isinstance(item, Resource): + jid = item.jid.bare + self.core.xmpp.plugin['xep_0191'].block(jid, callback=callback) + + def completion_block(self, the_input): + """ + Completion for /block + """ + if the_input.get_argument_position() == 1: + jids = roster.jids() + return the_input.new_completion(jids, 1, '', quotify=False) + + @command_args_parser.quoted(0, 1) + def command_unblock(self, args): + """ + /unblock [jid] + """ + def callback(iq): + if iq['type'] == 'error': + return self.core.information('Could not unblock the contact.', 'Error') + elif iq['type'] == 'result': + return self.core.information('Contact unblocked.', 'Info') + + item = self.roster_win.selected_row + if args: + jid = safeJID(args[0]) + elif isinstance(item, Contact): + jid = item.bare_jid + elif isinstance(item, Resource): + jid = item.jid.bare + self.core.xmpp.plugin['xep_0191'].unblock(jid, callback=callback) + + def completion_unblock(self, the_input): + """ + Completion for /unblock + """ + def on_result(iq): + if iq['type'] == 'error': + return + l = sorted(str(item) for item in iq['blocklist']['items']) + return the_input.new_completion(l, 1, quotify=False) + + if the_input.get_argument_position(): + self.core.xmpp.plugin['xep_0191'].get_blocked(callback=on_result) + return True + + @command_args_parser.ignored + def command_list_blocks(self): + """ + /list_blocks + """ + def callback(iq): + if iq['type'] == 'error': + return self.core.information('Could not retrieve the blocklist.', 'Error') + s = 'List of blocked JIDs:\n' + items = (str(item) for item in iq['blocklist']['items']) + jids = '\n'.join(items) + if jids: + s += jids + else: + s = 'No blocked JIDs.' + self.core.information(s, 'Info') + + self.core.xmpp.plugin['xep_0191'].get_blocked(callback=callback) + + @command_args_parser.ignored + def command_reconnect(self): + """ + /reconnect + """ + if self.core.xmpp.is_connected(): + self.core.disconnect(reconnect=True) + else: + self.core.xmpp.connect() + + @command_args_parser.ignored + def command_disconnect(self): + """ + /disconnect + """ + self.core.disconnect() + + @command_args_parser.quoted(0, 1) + def command_last_activity(self, args): + """ + /activity [jid] + """ + item = self.roster_win.selected_row + if args: + jid = args[0] + elif isinstance(item, Contact): + jid = item.bare_jid + elif isinstance(item, Resource): + jid = item.jid + else: + self.core.information('No JID selected.', 'Error') + return + self.core.command_last_activity(jid) + + def resize(self): + self.need_resize = False + if self.size.tab_degrade_x: + display_info = False + roster_width = self.width + else: + display_info = True + roster_width = self.width // 2 + if self.size.tab_degrade_y: + display_contact_win = False + contact_win_h = 0 + else: + display_contact_win = True + contact_win_h = 4 + if self.size.tab_degrade_y: + tab_win_height = 0 + else: + tab_win_height = Tab.tab_win_height() + + info_width = self.width - roster_width - 1 + if display_info: + self.v_separator.resize(self.height - 1 - tab_win_height, + 1, 0, roster_width) + self.information_win.resize(self.height - 1 - tab_win_height + - contact_win_h, + info_width, 0, roster_width + 1, + self.core.information_buffer) + if display_contact_win: + self.contact_info_win.resize(contact_win_h, + info_width, + self.height - tab_win_height + - contact_win_h - 1, + roster_width + 1) + self.roster_win.resize(self.height - 1 - Tab.tab_win_height(), + roster_width, 0, 0) + self.input.resize(1, self.width, self.height-1, 0) + self.default_help_message.resize(1, self.width, self.height-1, 0) + + def completion(self): + # Check if we are entering a command (with the '/' key) + if isinstance(self.input, windows.Input) and\ + not self.input.help_message: + self.complete_commands(self.input) + + def completion_file(self, complete_number, the_input): + """ + Generic quoted completion for files/paths + (use functools.partial to use directly as a completion + for a command) + """ + text = the_input.get_text() + args = common.shell_split(text) + n = the_input.get_argument_position() + if n == complete_number: + if args[n-1] == '' or len(args) < n+1: + home = os.getenv('HOME') or '/' + return the_input.new_completion([home, '/tmp'], n, quotify=True) + path_ = args[n] + if path.isdir(path_): + dir_ = path_ + base = '' + else: + dir_ = path.dirname(path_) + base = path.basename(path_) + try: + names = os.listdir(dir_) + except OSError: + names = [] + names_filtered = [name for name in names if name.startswith(base)] + if names_filtered: + names = names_filtered + if not names: + names = [path_] + end_list = [] + for name in names: + value = os.path.join(dir_, name) + if not name.startswith('.'): + end_list.append(value) + + return the_input.new_completion(end_list, n, quotify=True) + + @command_args_parser.ignored + def command_clear(self): + """ + /clear + """ + self.core.information_buffer.messages = [] + self.information_win.rebuild_everything(self.core.information_buffer) + self.core.information_win.rebuild_everything(self.core.information_buffer) + self.refresh() + + @command_args_parser.quoted(1) + def command_password(self, args): + """ + /password <password> + """ + def callback(iq): + if iq['type'] == 'result': + self.core.information('Password updated', 'Account') + if config.get('password'): + config.silent_set('password', args[0]) + else: + self.core.information('Unable to change the password', 'Account') + self.core.xmpp.plugin['xep_0077'].change_password(args[0], callback=callback) + + @command_args_parser.quoted(0, 1) + def command_deny(self, args): + """ + /deny [jid] + Denies a JID from our roster + """ + if not args: + item = self.roster_win.selected_row + if isinstance(item, Contact): + jid = item.bare_jid + else: + self.core.information('No subscription to deny') + return + else: + jid = safeJID(args[0]).bare + if not jid in [jid for jid in roster.jids()]: + self.core.information('No subscription to deny') + return + + contact = roster[jid] + if contact: + contact.unauthorize() + self.core.information('Subscription to %s was revoked' % jid, + 'Roster') + + @command_args_parser.quoted(1) + def command_add(self, args): + """ + Add the specified JID to the roster, and set automatically + accept the reverse subscription + """ + if args is None: + self.core.information('No JID specified', 'Error') + return + jid = safeJID(safeJID(args[0]).bare) + if not str(jid): + self.core.information('The provided JID (%s) is not valid' % (args[0],), 'Error') + return + if jid in roster and roster[jid].subscription in ('to', 'both'): + return self.core.information('Already subscribed.', 'Roster') + roster.add(jid) + roster.modified() + self.core.information('%s was added to the roster' % jid, 'Roster') + + @command_args_parser.quoted(1, 1) + def command_name(self, args): + """ + Set a name for the specified JID in your roster + """ + def callback(iq): + if not iq: + self.core.information('The name could not be set.', 'Error') + log.debug('Error in /name:\n%s', iq) + if args is None: + return self.core.command_help('name') + jid = safeJID(args[0]).bare + name = args[1] if len(args) == 2 else '' + + contact = roster[jid] + if contact is None: + self.core.information('No such JID in roster', 'Error') + return + + groups = set(contact.groups) + if 'none' in groups: + groups.remove('none') + subscription = contact.subscription + self.core.xmpp.update_roster(jid, name=name, groups=groups, + subscription=subscription, callback=callback) + + @command_args_parser.quoted(2) + def command_groupadd(self, args): + """ + Add the specified JID to the specified group + """ + if args is None: + return self.core.command_help('groupadd') + jid = safeJID(args[0]).bare + group = args[1] + + contact = roster[jid] + if contact is None: + self.core.information('No such JID in roster', 'Error') + return + + new_groups = set(contact.groups) + if group in new_groups: + self.core.information('JID already in group', 'Error') + return + + roster.modified() + new_groups.add(group) + try: + new_groups.remove('none') + except KeyError: + pass + + name = contact.name + subscription = contact.subscription + + def callback(iq): + if iq: + roster.update_contact_groups(jid) + else: + self.core.information('The group could not be set.', 'Error') + log.debug('Error in groupadd:\n%s', iq) + + self.core.xmpp.update_roster(jid, name=name, groups=new_groups, + subscription=subscription, callback=callback) + + @command_args_parser.quoted(3) + def command_groupmove(self, args): + """ + Remove the specified JID from the first specified group and add it to the second one + """ + if args is None: + return self.core.command_help('groupmove') + jid = safeJID(args[0]).bare + group_from = args[1] + group_to = args[2] + + contact = roster[jid] + if not contact: + self.core.information('No such JID in roster', 'Error') + return + + new_groups = set(contact.groups) + if 'none' in new_groups: + new_groups.remove('none') + + if group_to == 'none' or group_from == 'none': + self.core.information('"none" is not a group.', 'Error') + return + + if group_from not in new_groups: + self.core.information('JID not in first group', 'Error') + return + + if group_to in new_groups: + self.core.information('JID already in second group', 'Error') + return + + if group_to == group_from: + self.core.information('The groups are the same.', 'Error') + return + + roster.modified() + new_groups.add(group_to) + if 'none' in new_groups: + new_groups.remove('none') + + new_groups.remove(group_from) + name = contact.name + subscription = contact.subscription + + def callback(iq): + if iq: + roster.update_contact_groups(contact) + else: + self.core.information('The group could not be set') + log.debug('Error in groupmove:\n%s', iq) + + self.core.xmpp.update_roster(jid, name=name, groups=new_groups, + subscription=subscription, callback=callback) + + @command_args_parser.quoted(2) + def command_groupremove(self, args): + """ + Remove the specified JID from the specified group + """ + if args is None: + return self.core.command_help('groupremove') + + jid = safeJID(args[0]).bare + group = args[1] + + contact = roster[jid] + if contact is None: + self.core.information('No such JID in roster', 'Error') + return + + new_groups = set(contact.groups) + try: + new_groups.remove('none') + except KeyError: + pass + if group not in new_groups: + self.core.information('JID not in group', 'Error') + return + + roster.modified() + + new_groups.remove(group) + name = contact.name + subscription = contact.subscription + + def callback(iq): + if iq: + roster.update_contact_groups(jid) + else: + self.core.information('The group could not be set') + log.debug('Error in groupremove:\n%s', iq) + + self.core.xmpp.update_roster(jid, name=name, groups=new_groups, + subscription=subscription, callback=callback) + + @command_args_parser.quoted(0, 1) + def command_remove(self, args): + """ + Remove the specified JID from the roster. i.e.: unsubscribe + from its presence, and cancel its subscription to our. + """ + if args: + jid = safeJID(args[0]).bare + else: + item = self.roster_win.selected_row + if isinstance(item, Contact): + jid = item.bare_jid + else: + self.core.information('No roster item to remove') + return + roster.remove(jid) + del roster[jid] + + @command_args_parser.quoted(0, 1) + def command_import(self, args): + """ + Import the contacts + """ + if args: + if args[0].startswith('/'): + filepath = args[0] + else: + filepath = path.join(getenv('HOME'), args[0]) + else: + filepath = path.join(getenv('HOME'), 'poezio_contacts') + if not path.isfile(filepath): + self.core.information('The file %s does not exist' % filepath, 'Error') + return + try: + handle = open(filepath, 'r', encoding='utf-8') + lines = handle.readlines() + handle.close() + except IOError: + self.core.information('Could not open %s' % filepath, 'Error') + log.error('Unable to correct a message', exc_info=True) + return + for jid in lines: + self.command_add(jid.lstrip('\n')) + self.core.information('Contacts imported from %s' % filepath, 'Info') + + @command_args_parser.quoted(0, 1) + def command_export(self, args): + """ + Export the contacts + """ + if args: + if args[0].startswith('/'): + filepath = args[0] + else: + filepath = path.join(getenv('HOME'), args[0]) + else: + filepath = path.join(getenv('HOME'), 'poezio_contacts') + if path.isfile(filepath): + self.core.information('The file already exists', 'Error') + return + elif not path.isdir(path.dirname(filepath)): + self.core.information('Parent directory not found', 'Error') + return + if roster.export(filepath): + self.core.information('Contacts exported to %s' % filepath, 'Info') + else: + self.core.information('Failed to export contacts to %s' % filepath, 'Info') + + def completion_remove(self, the_input): + """ + Completion for /remove + """ + jids = [jid for jid in roster.jids()] + return the_input.auto_completion(jids, '', quotify=False) + + def completion_name(self, the_input): + """Completion for /name""" + n = the_input.get_argument_position() + if n == 1: + jids = [jid for jid in roster.jids()] + return the_input.new_completion(jids, n, quotify=True) + return False + + def completion_groupadd(self, the_input): + n = the_input.get_argument_position() + if n == 1: + jids = sorted(jid for jid in roster.jids()) + return the_input.new_completion(jids, n, '', quotify=True) + elif n == 2: + groups = sorted(group for group in roster.groups if group != 'none') + return the_input.new_completion(groups, n, '', quotify=True) + return False + + def completion_groupmove(self, the_input): + args = common.shell_split(the_input.text) + n = the_input.get_argument_position() + if n == 1: + jids = sorted(jid for jid in roster.jids()) + return the_input.new_completion(jids, n, '', quotify=True) + elif n == 2: + contact = roster[args[1]] + if not contact: + return False + groups = list(contact.groups) + if 'none' in groups: + groups.remove('none') + return the_input.new_completion(groups, n, '', quotify=True) + elif n == 3: + groups = sorted(group for group in roster.groups) + return the_input.new_completion(groups, n, '', quotify=True) + return False + + def completion_groupremove(self, the_input): + args = common.shell_split(the_input.text) + n = the_input.get_argument_position() + if n == 1: + jids = sorted(jid for jid in roster.jids()) + return the_input.new_completion(jids, n, '', quotify=True) + elif n == 2: + contact = roster[args[1]] + if contact is None: + return False + groups = sorted(contact.groups) + try: + groups.remove('none') + except ValueError: + pass + return the_input.new_completion(groups, n, '', quotify=True) + return False + + def completion_deny(self, the_input): + """ + Complete the first argument from the list of the + contact with ask=='subscribe' + """ + jids = sorted(str(contact.bare_jid) for contact in roster.contacts.values() + if contact.pending_in) + return the_input.new_completion(jids, 1, '', quotify=False) + + @command_args_parser.quoted(0, 1) + def command_accept(self, args): + """ + Accept a JID from in roster. Authorize it AND subscribe to it + """ + if not args: + item = self.roster_win.selected_row + if isinstance(item, Contact): + jid = item.bare_jid + else: + self.core.information('No subscription to accept') + return + else: + jid = safeJID(args[0]).bare + nodepart = safeJID(jid).user + jid = safeJID(jid) + # crappy transports putting resources inside the node part + if '\\2f' in nodepart: + jid.user = nodepart.split('\\2f')[0] + contact = roster[jid] + if contact is None: + return + contact.pending_in = False + roster.modified() + self.core.xmpp.send_presence(pto=jid, ptype='subscribed') + self.core.xmpp.client_roster.send_last_presence() + if contact.subscription in ('from', 'none') and not contact.pending_out: + self.core.xmpp.send_presence(pto=jid, ptype='subscribe', pnick=self.core.own_nick) + + self.core.information('%s is now authorized' % jid, 'Roster') + + def refresh(self): + if self.need_resize: + self.resize() + log.debug(' TAB Refresh: %s', self.__class__.__name__) + + display_info = not self.size.tab_degrade_x + display_contact_win = not self.size.tab_degrade_y + + self.roster_win.refresh(roster) + if display_info: + self.v_separator.refresh() + self.information_win.refresh() + if display_contact_win: + self.contact_info_win.refresh( + self.roster_win.get_selected_row()) + self.refresh_tab_win() + self.input.refresh() + + def on_input(self, key, raw): + if key == '^M': + selected_row = self.roster_win.get_selected_row() + res = self.input.do_command(key, raw=raw) + if res and not isinstance(self.input, windows.Input): + return True + elif res: + return False + if key == '^M': + self.core.on_roster_enter_key(selected_row) + return selected_row + elif not raw and key in self.key_func: + return self.key_func[key]() + + @refresh_wrapper.conditional + def toggle_offline_show(self): + """ + Show or hide offline contacts + """ + option = 'roster_show_offline' + value = config.get(option) + success = config.silent_set(option, str(not value)) + roster.modified() + if not success: + self.core.information('Unable to write in the config file', 'Error') + return True + + def on_slash(self): + """ + '/' is pressed, we enter "input mode" + """ + if isinstance(self.input, windows.YesNoInput): + return + curses.curs_set(1) + self.input = windows.CommandInput("", self.reset_help_message, self.execute_slash_command) + self.input.resize(1, self.width, self.height-1, 0) + self.input.do_command("/") # we add the slash + + def reset_help_message(self, _=None): + self.input = self.default_help_message + if self.core.current_tab() is self: + curses.curs_set(0) + self.input.refresh() + self.core.doupdate() + return True + + def execute_slash_command(self, txt): + if txt.startswith('/'): + self.input.key_enter() + self.execute_command(txt) + return self.reset_help_message() + + def on_lose_focus(self): + self.state = 'normal' + + def on_gain_focus(self): + self.state = 'current' + if isinstance(self.input, windows.HelpText): + curses.curs_set(0) + else: + curses.curs_set(1) + + @refresh_wrapper.conditional + def move_cursor_down(self): + if isinstance(self.input, windows.Input) and not self.input.history_disabled: + return + return self.roster_win.move_cursor_down() + + @refresh_wrapper.conditional + def move_cursor_up(self): + if isinstance(self.input, windows.Input) and not self.input.history_disabled: + return + return self.roster_win.move_cursor_up() + + def move_cursor_to_prev_contact(self): + self.roster_win.move_cursor_up() + while not isinstance(self.roster_win.get_selected_row(), Contact): + if not self.roster_win.move_cursor_up(): + break + self.roster_win.refresh(roster) + + def move_cursor_to_next_contact(self): + self.roster_win.move_cursor_down() + while not isinstance(self.roster_win.get_selected_row(), Contact): + if not self.roster_win.move_cursor_down(): + break + self.roster_win.refresh(roster) + + def move_cursor_to_prev_group(self): + self.roster_win.move_cursor_up() + while not isinstance(self.roster_win.get_selected_row(), RosterGroup): + if not self.roster_win.move_cursor_up(): + break + self.roster_win.refresh(roster) + + def move_cursor_to_next_group(self): + self.roster_win.move_cursor_down() + while not isinstance(self.roster_win.get_selected_row(), RosterGroup): + if not self.roster_win.move_cursor_down(): + break + self.roster_win.refresh(roster) + + def on_scroll_down(self): + return self.roster_win.move_cursor_down(self.height // 2) + + def on_scroll_up(self): + return self.roster_win.move_cursor_up(self.height // 2) + + @refresh_wrapper.conditional + def on_space(self): + if isinstance(self.input, windows.Input): + return + selected_row = self.roster_win.get_selected_row() + if isinstance(selected_row, RosterGroup): + selected_row.toggle_folded() + roster.modified() + return True + elif isinstance(selected_row, Contact): + group = "none" + found_group = False + pos = self.roster_win.pos + while not found_group and pos >= 0: + row = self.roster_win.roster_cache[pos] + pos -= 1 + if isinstance(row, RosterGroup): + found_group = True + group = row.name + selected_row.toggle_folded(group) + roster.modified() + return True + return False + + def get_contact_version(self): + """ + Show the versions of the resource(s) currently selected + """ + selected_row = self.roster_win.get_selected_row() + if isinstance(selected_row, Contact): + for resource in selected_row.resources: + self.core.command_version(str(resource.jid)) + elif isinstance(selected_row, Resource): + self.core.command_version(str(selected_row.jid)) + else: + self.core.information('Nothing to get versions from', 'Info') + + def show_contact_info(self): + """ + Show the contact info (resource number, status, presence, etc) + when 'i' is pressed. + """ + selected_row = self.roster_win.get_selected_row() + if isinstance(selected_row, Contact): + cont = selected_row + res = selected_row.get_highest_priority_resource() + acc = [] + acc.append('Contact: %s (%s)' % (cont.bare_jid, res.presence if res else 'unavailable')) + if res: + acc.append('%s connected resource%s' % (len(cont), '' if len(cont) == 1 else 's')) + acc.append('Current status: %s' % res.status) + if cont.tune: + acc.append('Tune: %s' % common.format_tune_string(cont.tune)) + if cont.mood: + acc.append('Mood: %s' % cont.mood) + if cont.activity: + acc.append('Activity: %s' % cont.activity) + if cont.gaming: + acc.append('Game: %s' % (common.format_gaming_string(cont.gaming))) + msg = '\n'.join(acc) + elif isinstance(selected_row, Resource): + res = selected_row + msg = 'Resource: %s (%s)\nCurrent status: %s\nPriority: %s' % ( + res.jid, + res.presence, + res.status, + res.priority) + elif isinstance(selected_row, RosterGroup): + rg = selected_row + msg = 'Group: %s [%s/%s] contacts online' % ( + rg.name, + rg.get_nb_connected_contacts(), + len(rg),) + else: + msg = None + if msg: + self.core.information(msg, 'Info') + + def change_contact_name(self): + """ + Auto-fill a /name command when 'n' is pressed + """ + selected_row = self.roster_win.get_selected_row() + if isinstance(selected_row, Contact): + jid = selected_row.bare_jid + elif isinstance(selected_row, Resource): + jid = safeJID(selected_row.jid).bare + else: + return + self.on_slash() + self.input.text = '/name "%s" ' % jid + self.input.key_end() + self.input.refresh() + + @refresh_wrapper.always + def start_search(self): + """ + Start the search. The input should appear with a short instruction + in it. + """ + if isinstance(self.input, windows.YesNoInput): + return + curses.curs_set(1) + self.input = windows.CommandInput("[Search]", self.on_search_terminate, self.on_search_terminate, self.set_roster_filter) + self.input.resize(1, self.width, self.height-1, 0) + self.input.disable_history() + roster.modified() + self.refresh() + return True + + @refresh_wrapper.always + def start_search_slow(self): + if isinstance(self.input, windows.YesNoInput): + return + curses.curs_set(1) + self.input = windows.CommandInput("[Search]", self.on_search_terminate, self.on_search_terminate, self.set_roster_filter_slow) + self.input.resize(1, self.width, self.height-1, 0) + self.input.disable_history() + return True + + def set_roster_filter_slow(self, txt): + roster.contact_filter = (jid_and_name_match_slow, txt) + roster.modified() + self.refresh() + return False + + def set_roster_filter(self, txt): + roster.contact_filter = (jid_and_name_match, txt) + roster.modified() + self.refresh() + return False + + @refresh_wrapper.always + def on_search_terminate(self, txt): + curses.curs_set(0) + roster.contact_filter = None + self.reset_help_message() + roster.modified() + return True + + def on_close(self): + return + +def diffmatch(search, string): + """ + Use difflib and a loop to check if search_pattern can + be 'almost' found INSIDE a string. + 'almost' being defined by difflib + """ + if len(search) > len(string): + return False + l = len(search) + ratio = 0.7 + for i in range(len(string) - l + 1): + if difflib.SequenceMatcher(None, search, string[i:i+l]).ratio() >= ratio: + return True + return False + +def jid_and_name_match(contact, txt): + """ + Match jid with text precisely + """ + if not txt: + return True + txt = txt.lower() + if txt in safeJID(contact.bare_jid).bare.lower(): + return True + if txt in contact.name.lower(): + return True + return False + +def jid_and_name_match_slow(contact, txt): + """ + A function used to know if a contact in the roster should + be shown in the roster + """ + if not txt: + return True # Everything matches when search is empty + user = safeJID(contact.bare_jid).bare + if diffmatch(txt, user): + return True + if contact.name and diffmatch(txt, contact.name): + return True + return False diff --git a/poezio/tabs/xmltab.py b/poezio/tabs/xmltab.py new file mode 100644 index 00000000..b063ad35 --- /dev/null +++ b/poezio/tabs/xmltab.py @@ -0,0 +1,360 @@ +""" +The XMLTab is here for debugging purposes, it shows the incoming and +outgoing stanzas. It has a few useful functions that can filter stanzas +in order to only show the relevant ones, and it can also be frozen or +unfrozen on demand so that the relevant information is not drowned by +the traffic. +""" +import logging +log = logging.getLogger(__name__) + +import curses +import os +from slixmpp.xmlstream import matcher +from slixmpp.xmlstream.tostring import tostring +from slixmpp.xmlstream.stanzabase import ElementBase +from xml.etree import ElementTree as ET + +from . import Tab + +import text_buffer +import windows +from xhtml import clean_text +from decorators import command_args_parser, refresh_wrapper +from common import safeJID + + +class MatchJID(object): + + def __init__(self, jid, dest=''): + self.jid = jid + self.dest = dest + + def match(self, xml): + from_ = safeJID(xml['from']) + to_ = safeJID(xml['to']) + if self.jid.full == self.jid.bare: + from_ = from_.bare + to_ = to_.bare + + if self.dest == 'from': + return from_ == self.jid + elif self.dest == 'to': + return to_ == self.jid + return self.jid in (from_, to_) + + def __repr__(self): + return '%s%s%s' % (self.dest, ': ' if self.dest else '', self.jid) + +MATCHERS_MAPPINGS = { + MatchJID: ('JID', lambda obj: repr(obj)), + matcher.MatcherId: ('ID', lambda obj: obj._criteria), + matcher.MatchXMLMask: ('XMLMask', lambda obj: tostring(obj._criteria)), + matcher.MatchXPath: ('XPath', lambda obj: obj._criteria) +} + +class XMLTab(Tab): + def __init__(self): + Tab.__init__(self) + self.state = 'normal' + self.name = 'XMLTab' + self.filters = [] + + self.core_buffer = self.core.xml_buffer + self.filtered_buffer = text_buffer.TextBuffer() + + self.info_header = windows.XMLInfoWin() + self.text_win = windows.XMLTextWin() + self.core_buffer.add_window(self.text_win) + self.default_help_message = windows.HelpText("/ to enter a command") + + self.register_command('close', self.close, + shortdesc="Close this tab.") + self.register_command('clear', self.command_clear, + shortdesc='Clear the current buffer.') + self.register_command('reset', self.command_reset, + shortdesc='Reset the stanza filter.') + self.register_command('filter_id', self.command_filter_id, + usage='<id>', + desc='Show only the stanzas with the id <id>.', + shortdesc='Filter by id.') + self.register_command('filter_xpath', self.command_filter_xpath, + usage='<xpath>', + desc='Show only the stanzas matching the xpath <xpath>.' + ' Any occurrences of %n will be replaced by jabber:client.', + shortdesc='Filter by XPath.') + self.register_command('filter_jid', self.command_filter_jid, + usage='<jid>', + desc='Show only the stanzas matching the jid <jid> in from= or to=.', + shortdesc='Filter by JID.') + self.register_command('filter_from', self.command_filter_from, + usage='<jid>', + desc='Show only the stanzas matching the jid <jid> in from=.', + shortdesc='Filter by JID from.') + self.register_command('filter_to', self.command_filter_to, + usage='<jid>', + desc='Show only the stanzas matching the jid <jid> in to=.', + shortdesc='Filter by JID to.') + self.register_command('filter_xmlmask', self.command_filter_xmlmask, + usage='<xml mask>', + desc='Show only the stanzas matching the given xml mask.', + shortdesc='Filter by xml mask.') + self.register_command('dump', self.command_dump, + usage='<filename>', + desc='Writes the content of the XML buffer into a file.', + shortdesc='Write in a file.') + self.input = self.default_help_message + self.key_func['^T'] = self.close + self.key_func['^I'] = self.completion + self.key_func["KEY_DOWN"] = self.on_scroll_down + self.key_func["KEY_UP"] = self.on_scroll_up + self.key_func["^K"] = self.on_freeze + self.key_func["/"] = self.on_slash + self.resize() + # Used to display the infobar + self.filter_type = '' + self.filter = '' + + def gen_filter_repr(self): + if not self.filters: + self.filter_type = '' + self.filter = '' + return + filter_types = map(lambda x: MATCHERS_MAPPINGS[type(x)][0], self.filters) + filter_strings = map(lambda x: MATCHERS_MAPPINGS[type(x)][1](x), self.filters) + self.filter_type = ','.join(filter_types) + self.filter = ','.join(filter_strings) + + def update_filters(self, matcher): + if not self.filters: + messages = self.core_buffer.messages[:] + self.filtered_buffer.messages = [] + self.core_buffer.del_window(self.text_win) + self.filtered_buffer.add_window(self.text_win) + else: + messages = self.filtered_buffer.messages + self.filtered_buffer.messages = [] + self.filters.append(matcher) + new_messages = [] + for msg in messages: + try: + if msg.txt.strip() and self.match_stanza(ElementBase(ET.fromstring(clean_text(msg.txt)))): + new_messages.append(msg) + except ET.ParseError: + log.debug('Malformed XML : %s', msg.txt, exc_info=True) + self.filtered_buffer.messages = new_messages + self.text_win.rebuild_everything(self.filtered_buffer) + self.gen_filter_repr() + + def on_freeze(self): + """ + Freeze the display. + """ + self.text_win.toggle_lock() + self.refresh() + + def match_stanza(self, stanza): + for matcher in self.filters: + if not matcher.match(stanza): + return False + return True + + @command_args_parser.raw + def command_filter_xmlmask(self, mask): + """/filter_xmlmask <xml mask>""" + try: + self.update_filters(matcher.MatchXMLMask(mask)) + self.refresh() + except Exception as e: + self.core.information('Invalid XML Mask: %s' % e, 'Error') + self.command_reset('') + + @command_args_parser.raw + def command_filter_to(self, jid): + """/filter_jid_to <jid>""" + jid_obj = safeJID(jid) + if not jid_obj: + return self.core.information('Invalid JID: %s' % jid, 'Error') + + self.update_filters(MatchJID(jid_obj, dest='to')) + self.refresh() + + @command_args_parser.raw + def command_filter_from(self, jid): + """/filter_jid_from <jid>""" + jid_obj = safeJID(jid) + if not jid_obj: + return self.core.information('Invalid JID: %s' % jid, 'Error') + + self.update_filters(MatchJID(jid_obj, dest='from')) + self.refresh() + + @command_args_parser.raw + def command_filter_jid(self, jid): + """/filter_jid <jid>""" + jid_obj = safeJID(jid) + if not jid_obj: + return self.core.information('Invalid JID: %s' % jid, 'Error') + + self.update_filters(MatchJID(jid_obj)) + self.refresh() + + @command_args_parser.quoted(1) + def command_filter_id(self, args): + """/filter_id <id>""" + if args is None: + return self.core.command_help('filter_id') + + self.update_filters(matcher.MatcherId(args[0])) + self.refresh() + + @command_args_parser.raw + def command_filter_xpath(self, xpath): + """/filter_xpath <xpath>""" + try: + self.update_filters(matcher.MatchXPath(xpath.replace('%n', self.core.xmpp.default_ns))) + self.refresh() + except: + self.core.information('Invalid XML Path', 'Error') + self.command_reset('') + + @command_args_parser.ignored + def command_reset(self): + """/reset""" + if self.filters: + self.filters = [] + self.filtered_buffer.del_window(self.text_win) + self.core_buffer.add_window(self.text_win) + self.text_win.rebuild_everything(self.core_buffer) + self.filter_type = '' + self.filter = '' + self.refresh() + + @command_args_parser.quoted(1) + def command_dump(self, args): + """/dump <filename>""" + if args is None: + return self.core.command_help('dump') + if self.filters: + xml = self.filtered_buffer.messages[:] + else: + xml = self.core_buffer.messages[:] + text = '\n'.join(('%s %s %s' % (msg.str_time, msg.nickname, clean_text(msg.txt)) for msg in xml)) + filename = os.path.expandvars(os.path.expanduser(args[0])) + try: + with open(filename, 'w') as fd: + fd.write(text) + except Exception as e: + self.core.information('Could not write the XML dump: %s' % e, 'Error') + + def on_slash(self): + """ + '/' is pressed, activate the input + """ + curses.curs_set(1) + self.input = windows.CommandInput("", self.reset_help_message, self.execute_slash_command) + self.input.resize(1, self.width, self.height-1, 0) + self.input.do_command("/") # we add the slash + + @refresh_wrapper.always + def reset_help_message(self, _=None): + if self.closed: + return True + if self.core.current_tab() is self: + curses.curs_set(0) + self.input = self.default_help_message + return True + + def on_scroll_up(self): + return self.text_win.scroll_up(self.text_win.height-1) + + def on_scroll_down(self): + return self.text_win.scroll_down(self.text_win.height-1) + + @command_args_parser.ignored + def command_clear(self): + """ + /clear + """ + if self.filters: + buffer = self.core_buffer + else: + buffer = self.filtered_buffer + buffer.messages = [] + self.text_win.rebuild_everything(buffer) + self.refresh() + self.core.doupdate() + + def execute_slash_command(self, txt): + if txt.startswith('/'): + self.input.key_enter() + self.execute_command(txt) + return self.reset_help_message() + + def completion(self): + if isinstance(self.input, windows.Input): + self.complete_commands(self.input) + + def on_input(self, key, raw): + res = self.input.do_command(key, raw=raw) + if res: + return True + if not raw and key in self.key_func: + return self.key_func[key]() + + def close(self, arg=None): + self.core.close_tab() + + def resize(self): + self.need_resize = False + if self.size.tab_degrade_y: + info_win_size = 0 + tab_win_height = 0 + else: + info_win_size = self.core.information_win_size + tab_win_height = Tab.tab_win_height() + + self.text_win.resize(self.height - info_win_size - tab_win_height - 2, + self.width, 0, 0) + self.text_win.rebuild_everything(self.core.xml_buffer) + self.info_header.resize(1, self.width, + self.height - 2 - info_win_size + - tab_win_height, + 0) + self.input.resize(1, self.width, self.height-1, 0) + + def refresh(self): + if self.need_resize: + self.resize() + log.debug(' TAB Refresh: %s', self.__class__.__name__) + + if self.size.tab_degrade_y: + display_info_win = False + else: + display_info_win = True + + self.text_win.refresh() + self.info_header.refresh(self.filter_type, self.filter, self.text_win) + self.refresh_tab_win() + if display_info_win: + self.info_win.refresh() + self.input.refresh() + + def on_lose_focus(self): + self.state = 'normal' + + def on_gain_focus(self): + self.state = 'current' + curses.curs_set(0) + + def on_close(self): + self.command_clear('') + self.core.xml_tab = False + + def on_info_win_size_changed(self): + if self.core.information_win_size >= self.height-3: + return + self.text_win.resize(self.height-2-self.core.information_win_size - Tab.tab_win_height(), self.width, 0, 0) + self.info_header.resize(1, self.width, self.height-2-self.core.information_win_size - Tab.tab_win_height(), 0) + + diff --git a/poezio/text_buffer.py b/poezio/text_buffer.py new file mode 100644 index 00000000..dd5bc58a --- /dev/null +++ b/poezio/text_buffer.py @@ -0,0 +1,242 @@ +""" +Define the TextBuffer class + +A text buffer contains a list of intermediate representations of messages +(not xml stanzas, but neither the Lines used in windows.py. + +Each text buffer can be linked to multiple windows, that will be rendered +independantly by their TextWins. +""" + +import logging +log = logging.getLogger(__name__) + +import collections + +from datetime import datetime +from config import config +from theming import get_theme, dump_tuple + +message_fields = ('txt nick_color time str_time nickname user identifier' + ' highlight me old_message revisions jid ack') +Message = collections.namedtuple('Message', message_fields) + +class CorrectionError(Exception): + pass + +class AckError(Exception): + pass + +def other_elems(self): + "Helper for the repr_message function" + acc = ['Message('] + fields = message_fields.split() + fields.remove('old_message') + for field in fields: + acc.append('%s=%s' % (field, repr(getattr(self, field)))) + return ', '.join(acc) + ', old_message=' + +def repr_message(self): + """ + repr() for the Message class, for debug purposes, since the default + repr() is recursive, so it can stack overflow given too many revisions + of a message + """ + init = other_elems(self) + acc = [init] + next_message = self.old_message + rev = 1 + while next_message: + acc.append(other_elems(next_message)) + next_message = next_message.old_message + rev += 1 + acc.append('None') + while rev: + acc.append(')') + rev -= 1 + return ''.join(acc) + +Message.__repr__ = repr_message +Message.__str__ = repr_message + +class TextBuffer(object): + """ + This class just keep trace of messages, in a list with various + informations and attributes. + """ + def __init__(self, messages_nb_limit=None): + + if messages_nb_limit is None: + messages_nb_limit = config.get('max_messages_in_memory') + self.messages_nb_limit = messages_nb_limit + # Message objects + self.messages = [] + # we keep track of one or more windows + # so we can pass the new messages to them, as they are added, so + # they (the windows) can build the lines from the new message + self.windows = [] + + def add_window(self, win): + self.windows.append(win) + + @property + def last_message(self): + return self.messages[-1] if self.messages else None + + + @staticmethod + def make_message(txt, time, nickname, nick_color, history, user, + identifier, str_time=None, highlight=False, + old_message=None, revisions=0, jid=None, ack=0): + """ + Create a new Message object with parameters, check for /me messages, + and delayed messages + """ + time = time or datetime.now() + if txt.startswith('/me '): + me = True + txt = '\x19%s}%s' % (dump_tuple(get_theme().COLOR_ME_MESSAGE), + txt[4:]) + else: + me = False + if history: + txt = txt.replace('\x19o', '\x19o\x19%s}' % + dump_tuple(get_theme().COLOR_LOG_MSG)) + str_time = time.strftime("%Y-%m-%d %H:%M:%S") + else: + if str_time is None: + str_time = time.strftime("%H:%M:%S") + else: + str_time = '' + + msg = Message( + txt='%s\x19o'%(txt.replace('\t', ' '),), + nick_color=nick_color, + time=time, + str_time=str_time, + nickname=nickname, + user=user, + identifier=identifier, + highlight=highlight, + me=me, + old_message=old_message, + revisions=revisions, + jid=jid, + ack=ack) + log.debug('Set message %s with %s.', identifier, msg) + return msg + + def add_message(self, txt, time=None, nickname=None, + nick_color=None, history=None, user=None, highlight=False, + identifier=None, str_time=None, jid=None, ack=0): + """ + Create a message and add it to the text buffer + """ + msg = self.make_message(txt, time, nickname, nick_color, history, + user, identifier, str_time=str_time, + highlight=highlight, jid=jid, ack=ack) + self.messages.append(msg) + + while len(self.messages) > self.messages_nb_limit: + self.messages.pop(0) + + ret_val = None + show_timestamps = config.get('show_timestamps') + nick_size = config.get('max_nick_length') + for window in self.windows: # make the associated windows + # build the lines from the new message + nb = window.build_new_message(msg, history=history, + highlight=highlight, + timestamp=show_timestamps, + nick_size=nick_size) + if ret_val is None: + ret_val = nb + if window.pos != 0: + window.scroll_up(nb) + + return ret_val or 1 + + def _find_message(self, old_id): + """ + Find a message in the text buffer from its message id + """ + for i in range(len(self.messages) -1, -1, -1): + msg = self.messages[i] + if msg.identifier == old_id: + return i + return -1 + + def ack_message(self, old_id, jid): + """Mark a message as acked""" + return self.edit_ack(1, old_id, jid) + + def nack_message(self, error, old_id, jid): + """Mark a message as errored""" + return self.edit_ack(-1, old_id, jid, append=error) + + def edit_ack(self, value, old_id, jid, append=''): + """ + Edit the ack status of a message, and optionally + append some text. + """ + i = self._find_message(old_id) + if i == -1: + return + msg = self.messages[i] + if msg.jid != jid: + raise AckError('Wrong JID for message id %s (was %s, expected %s)' % + (old_id, msg.jid, jid)) + + new_msg = list(msg) + new_msg[12] = value + if append: + new_msg[0] = new_msg[0] + append + new_msg = Message(*new_msg) + self.messages[i] = new_msg + return new_msg + + def modify_message(self, txt, old_id, new_id, highlight=False, + time=None, user=None, jid=None): + """ + Correct a message in a text buffer. + """ + + i = self._find_message(old_id) + + if i == -1: + log.debug('Message %s not found in text_buffer, abort replacement.', + old_id) + raise CorrectionError("nothing to replace") + + msg = self.messages[i] + + if msg.user and msg.user is not user: + raise CorrectionError("Different users") + elif len(msg.str_time) > 8: # ugly + raise CorrectionError("Delayed message") + elif not msg.user and (msg.jid is None or jid is None): + raise CorrectionError('Could not check the ' + 'identity of the sender') + elif not msg.user and msg.jid != jid: + raise CorrectionError('Messages %s and %s have not been ' + 'sent by the same fullJID' % + (old_id, new_id)) + + if not time: + time = msg.time + message = self.make_message(txt, time, msg.nickname, + msg.nick_color, None, msg.user, + new_id, highlight=highlight, + old_message=msg, + revisions=msg.revisions + 1, + jid=jid) + self.messages[i] = message + log.debug('Replacing message %s with %s.', old_id, new_id) + return message + + def del_window(self, win): + self.windows.remove(win) + + def __del__(self): + size = len(self.messages) + log.debug('** Deleting %s messages from textbuffer', size) diff --git a/poezio/theming.py b/poezio/theming.py new file mode 100755 index 00000000..5d263741 --- /dev/null +++ b/poezio/theming.py @@ -0,0 +1,534 @@ +# Copyright 2010-2011 Florent Le Coz <louiz@louiz.org> +# +# This file is part of Poezio. +# +# Poezio is free software: you can redistribute it and/or modify +# it under the terms of the zlib license. See the COPYING file. + +""" +Define the variables (colors and some other stuff) that are +used when drawing the interface. + +Colors are numbers from -1 to 7 (if only 8 colors are supported) or -1 to 255 +if 256 colors are available. +If only 8 colors are available, all colors > 8 are converted using the +table_256_to_16 dict. + +XHTML-IM colors are converted to -1 -> 255 colors if available, or directly to +-1 -> 8 if we are in 8-color-mode. + +A pair_color is a background-foreground pair. All possible pairs are not created +at startup, because that would create 256*256 pairs, and almost all of them +would never be used. + +A theme should define color tuples, like ``(200, -1)``, and when they are to +be used by poezio's interface, they will be created once, and kept in a list for +later usage. +A color tuple is of the form ``(foreground, background, optional)`` +A color of -1 means the default color. So if you do not want to have +a background color, use ``(x, -1)``. +The optional third value of the tuple defines additional information. It +is a string and can contain one or more of these characters: + +- ``b``: bold +- ``u``: underlined +- ``x``: blink + +For example, ``(200, 208, 'bu')`` is bold, underlined and pink foreground on +orange background. + +A theme file is a python file containing one object named 'theme', which is an +instance of a class (derived from the Theme class) defined in that same file. +For example, in pinkytheme.py: + +.. code-block:: python + + import theming + class PinkyTheme(theming.Theme): + COLOR_NORMAL_TEXT = (200, -1) + + theme = PinkyTheme() + +if the command '/theme pinkytheme' is issued, we import the pinkytheme.py file +and set the global variable 'theme' to pinkytheme.theme. + +And in poezio's code we just use ``theme.COLOR_NORMAL_TEXT`` etc + +Since a theme inherites from the Theme class (defined here), if a color is not defined in a +theme file, the color is the default one. + +Some values in that class are a list of color tuple. +For example ``[(1, -1), (2, -1), (3, -1)]`` +Such a list SHOULD contain at least one color tuple. +It is used for example to define color gradient, etc. +""" + +import logging +log = logging.getLogger(__name__) + +from config import config + +import curses +import os +from os import path + +from importlib import machinery +finder = machinery.PathFinder() + +class Theme(object): + """ + The theme class, from which all themes should inherit. + All of the following values can be replaced in subclasses, in + order to create a new theme. + + Do not edit this file if you want to change the theme to suit your + needs. Create a new theme and share it if you think it can be useful + for others. + """ + @classmethod + def color_role(cls, role): + role_mapping = { + 'moderator': cls.COLOR_USER_MODERATOR, + 'participant': cls.COLOR_USER_PARTICIPANT, + 'visitor': cls.COLOR_USER_VISITOR, + 'none': cls.COLOR_USER_NONE, + '': cls.COLOR_USER_NONE + } + return role_mapping.get(role, cls.COLOR_USER_NONE) + + @classmethod + def char_affiliation(cls, affiliation): + affiliation_mapping = { + 'owner': cls.CHAR_AFFILIATION_OWNER, + 'admin': cls.CHAR_AFFILIATION_ADMIN, + 'member': cls.CHAR_AFFILIATION_MEMBER, + 'none': cls.CHAR_AFFILIATION_NONE + } + return affiliation_mapping.get(affiliation, cls.CHAR_AFFILIATION_NONE) + + @classmethod + def color_show(cls, show): + show_mapping = { + 'xa': cls.COLOR_STATUS_XA, + 'none': cls.COLOR_STATUS_NONE, + 'dnd': cls.COLOR_STATUS_DND, + 'away': cls.COLOR_STATUS_AWAY, + 'chat': cls.COLOR_STATUS_CHAT, + '': cls.COLOR_STATUS_ONLINE, + 'available': cls.COLOR_STATUS_ONLINE, + 'unavailable': cls.COLOR_STATUS_UNAVAILABLE, + } + return show_mapping.get(show, cls.COLOR_STATUS_NONE) + + @classmethod + def char_subscription(cls, sub, keep='incomplete'): + sub_mapping = { + 'from': cls.CHAR_ROSTER_FROM, + 'both': cls.CHAR_ROSTER_BOTH, + 'none': cls.CHAR_ROSTER_NONE, + 'to': cls.CHAR_ROSTER_TO, + } + if keep == 'incomplete' and sub == 'both': + return '' + if keep in ('both', 'none', 'to', 'from'): + return sub_mapping[sub] if sub == keep else '' + return sub_mapping.get(sub, '') + + # Message text color + COLOR_NORMAL_TEXT = (-1, -1) + COLOR_INFORMATION_TEXT = (5, -1) # TODO + COLOR_WARNING_TEXT = (1, -1) + + # Color of the commands in the help message + COLOR_HELP_COMMANDS = (208, -1) + + # "reverse" is a special value, available only for this option. It just + # takes the nick colors and reverses it. A theme can still specify a + # fixed color if need be. + COLOR_HIGHLIGHT_NICK = "reverse" + + # Color of the participant JID in a MUC + COLOR_MUC_JID = (4, -1) + + # User list color + COLOR_USER_VISITOR = (239, -1) + COLOR_USER_PARTICIPANT = (4, -1) + COLOR_USER_NONE = (0, -1) + COLOR_USER_MODERATOR = (1, -1) + + # nickname colors + COLOR_REMOTE_USER = (5, -1) + + # The character printed in color (COLOR_STATUS_*) before the nickname + # in the user list + CHAR_STATUS = '|' + + # The characters used for the chatstates in the user list + # in a MUC + CHAR_CHATSTATE_ACTIVE = 'A' + CHAR_CHATSTATE_COMPOSING = 'X' + CHAR_CHATSTATE_PAUSED = 'p' + + # These characters are used for the affiliation in the user list + # in a MUC + CHAR_AFFILIATION_OWNER = '~' + CHAR_AFFILIATION_ADMIN = '&' + CHAR_AFFILIATION_MEMBER = '+' + CHAR_AFFILIATION_NONE = '-' + + + # XML Tab + CHAR_XML_IN = 'IN ' + CHAR_XML_OUT = 'OUT' + COLOR_XML_IN = (1, -1) + COLOR_XML_OUT = (2, -1) + + # Color for the /me message + COLOR_ME_MESSAGE = (6, -1) + + # Color for the number of revisions of a message + COLOR_REVISIONS_MESSAGE = (3, -1, 'b') + + # Color for various important text. For example the "?" before JIDs in + # the roster that require an user action. + COLOR_IMPORTANT_TEXT = (3, 5, 'b') + + # Separators + COLOR_VERTICAL_SEPARATOR = (4, -1) + COLOR_NEW_TEXT_SEPARATOR = (2, -1) + COLOR_MORE_INDICATOR = (6, 4) + + # Time + CHAR_TIME_LEFT = '' + CHAR_TIME_RIGHT = '' + COLOR_TIME_STRING = (-1, -1) + + # Tabs + COLOR_TAB_NORMAL = (7, 4) + COLOR_TAB_NONEMPTY = (7, 4) + COLOR_TAB_SCROLLED = (5, 4) + COLOR_TAB_JOINED = (82, 4) + COLOR_TAB_CURRENT = (7, 6) + COLOR_TAB_COMPOSING = (7, 5) + COLOR_TAB_NEW_MESSAGE = (7, 5) + COLOR_TAB_HIGHLIGHT = (7, 3) + COLOR_TAB_PRIVATE = (7, 2) + COLOR_TAB_ATTENTION = (7, 1) + COLOR_TAB_DISCONNECTED = (7, 8) + + COLOR_VERTICAL_TAB_NORMAL = (4, -1) + COLOR_VERTICAL_TAB_NONEMPTY = (4, -1) + COLOR_VERTICAL_TAB_JOINED = (82, -1) + COLOR_VERTICAL_TAB_SCROLLED = (66, -1) + COLOR_VERTICAL_TAB_CURRENT = (7, 4) + COLOR_VERTICAL_TAB_NEW_MESSAGE = (5, -1) + COLOR_VERTICAL_TAB_COMPOSING = (5, -1) + COLOR_VERTICAL_TAB_HIGHLIGHT = (3, -1) + COLOR_VERTICAL_TAB_PRIVATE = (2, -1) + COLOR_VERTICAL_TAB_ATTENTION = (1, -1) + COLOR_VERTICAL_TAB_DISCONNECTED = (8, -1) + + # Nickname colors + # A list of colors randomly attributed to nicks in MUCs + # Setting more colors makes it harder to have two nicks with the same color, + # avoiding confusions. + LIST_COLOR_NICKNAMES = [ + (1, -1), (2, -1), (3, -1), (4, -1), (5, -1), (6, -1), (9, -1), + (10, -1), (11, -1), (12, -1), (13, -1), (14, -1), (19, -1), + (20, -1), (21, -1), (22, -1), (23, -1), (24, -1), (25, -1), + (26, -1), (27, -1), (28, -1), (29, -1), (30, -1), (31, -1), + (32, -1), (33, -1), (34, -1), (35, -1), (36, -1), (37, -1), + (38, -1), (39, -1), (40, -1), (41, -1), (42, -1), (43, -1), + (44, -1), (45, -1), (46, -1), (47, -1), (48, -1), (49, -1), + (50, -1), (51, -1), (54, -1), (55, -1), (56, -1), (57, -1), + (58, -1), (60, -1), (61, -1), (62, -1), (63, -1), (64, -1), + (65, -1), (66, -1), (67, -1), (68, -1), (69, -1), (70, -1), + (71, -1), (72, -1), (73, -1), (74, -1), (75, -1), (76, -1), + (77, -1), (78, -1), (79, -1), (80, -1), (81, -1), (82, -1), + (83, -1), (84, -1), (85, -1), (86, -1), (87, -1), (88, -1), + (89, -1), (90, -1), (91, -1), (92, -1), (93, -1), (94, -1), + (95, -1), (96, -1), (97, -1), (98, -1), (99, -1), (100, -1), + (101, -1), (103, -1), (104, -1), (105, -1), (106, -1), (107, -1), + (108, -1), (109, -1), (110, -1), (111, -1), (112, -1), (113, -1), + (114, -1), (115, -1), (116, -1), (117, -1), (118, -1), (119, -1), + (120, -1), (121, -1), (122, -1), (123, -1), (124, -1), (125, -1), + (126, -1), (127, -1), (128, -1), (129, -1), (130, -1), (131, -1), + (132, -1), (133, -1), (134, -1), (135, -1), (136, -1), (137, -1), + (138, -1), (139, -1), (140, -1), (141, -1), (142, -1), (143, -1), + (144, -1), (145, -1), (146, -1), (147, -1), (148, -1), (149, -1), + (150, -1), (151, -1), (152, -1), (153, -1), (154, -1), (155, -1), + (156, -1), (157, -1), (158, -1), (159, -1), (160, -1), (161, -1), + (162, -1), (163, -1), (164, -1), (165, -1), (166, -1), (167, -1), + (168, -1), (169, -1), (170, -1), (171, -1), (172, -1), (173, -1), + (174, -1), (175, -1), (176, -1), (177, -1), (178, -1), (179, -1), + (180, -1), (181, -1), (182, -1), (183, -1), (184, -1), (185, -1), + (186, -1), (187, -1), (188, -1), (189, -1), (190, -1), (191, -1), + (192, -1), (193, -1), (196, -1), (197, -1), (198, -1), (199, -1), + (200, -1), (201, -1), (202, -1), (203, -1), (204, -1), (205, -1), + (206, -1), (207, -1), (208, -1), (209, -1), (210, -1), (211, -1), + (212, -1), (213, -1), (214, -1), (215, -1), (216, -1), (217, -1), + (218, -1), (219, -1), (220, -1), (221, -1), (222, -1), (223, -1), + (224, -1), (225, -1), (226, -1), (227, -1)] + + # This is your own nickname + COLOR_OWN_NICK = (254, -1) + + COLOR_LOG_MSG = (5, -1) + # This is for in-tab error messages + COLOR_ERROR_MSG = (9, 7, 'b') + # Status color + COLOR_STATUS_XA = (16, 90) + COLOR_STATUS_NONE = (16, 4) + COLOR_STATUS_DND = (16, 1) + COLOR_STATUS_AWAY = (16, 3) + COLOR_STATUS_CHAT = (16, 2) + COLOR_STATUS_UNAVAILABLE = (-1, 247) + COLOR_STATUS_ONLINE = (16, 4) + + # Bars + COLOR_WARNING_PROMPT = (16, 1, 'b') + COLOR_INFORMATION_BAR = (7, 4) + COLOR_TOPIC_BAR = (7, 4) + COLOR_SCROLLABLE_NUMBER = (220, 4, 'b') + COLOR_SELECTED_ROW = (-1, 33) + COLOR_PRIVATE_NAME = (-1, 4) + COLOR_CONVERSATION_NAME = (2, 4) + COLOR_CONVERSATION_RESOURCE = (121, 4) + COLOR_GROUPCHAT_NAME = (7, 4) + COLOR_COLUMN_HEADER = (36, 4) + COLOR_COLUMN_HEADER_SEL = (4, 36) + + # Strings for special messages (like join, quit, nick change, etc) + # Special messages + CHAR_JOIN = '--->' + CHAR_QUIT = '<---' + CHAR_KICK = '-!-' + CHAR_NEW_TEXT_SEPARATOR = '- ' + CHAR_OK = '✔' + CHAR_ERROR = '✖' + CHAR_EMPTY = ' ' + CHAR_ACK_RECEIVED = CHAR_OK + CHAR_NACK = CHAR_ERROR + CHAR_COLUMN_ASC = ' ▲' + CHAR_COLUMN_DESC = ' ▼' + CHAR_ROSTER_ERROR = CHAR_ERROR + CHAR_ROSTER_TUNE = '♪' + CHAR_ROSTER_ASKED = '?' + CHAR_ROSTER_ACTIVITY = 'A' + CHAR_ROSTER_MOOD = 'M' + CHAR_ROSTER_GAMING = 'G' + CHAR_ROSTER_FROM = '←' + CHAR_ROSTER_BOTH = '↔' + CHAR_ROSTER_TO = '→' + CHAR_ROSTER_NONE = '⇹' + + COLOR_CHAR_ACK = (2, -1) + COLOR_CHAR_NACK = (1, -1) + + COLOR_ROSTER_GAMING = (6, -1) + COLOR_ROSTER_MOOD = (2, -1) + COLOR_ROSTER_ACTIVITY = (3, -1) + COLOR_ROSTER_TUNE = (6, -1) + COLOR_ROSTER_ERROR = (1, -1) + COLOR_ROSTER_SUBSCRIPTION = (-1, -1) + + COLOR_JOIN_CHAR = (4, -1) + COLOR_QUIT_CHAR = (1, -1) + COLOR_KICK_CHAR = (1, -1) + + # Vertical tab list color + COLOR_VERTICAL_TAB_NUMBER = (34, -1) + + # Info messages color (the part before the ">") + INFO_COLORS = { + 'info': (5, -1), + 'error': (16, 1), + 'warning': (1, -1), + 'roster': (2, -1), + 'help': (10, -1), + 'headline': (11, -1, 'b'), + 'tune': (6, -1), + 'gaming': (6, -1), + 'mood': (2, -1), + 'activity': (3, -1), + 'default': (7, -1), + } + +# This is the default theme object, used if no theme is defined in the conf +theme = Theme() + +# a dict "color tuple -> color_pair" +# Each time we use a color tuple, we check if it has already been used. +# If not we create a new color_pair and keep it in that dict, to use it +# the next time. +curses_colors_dict = {} + +table_256_to_16 = [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, + 0, 4, 4, 4, 12, 12, 2, 6, 4, 4, 12, 12, 2, 2, 6, 4, + 12, 12, 2, 2, 2, 6, 12, 12, 10, 10, 10, 10, 14, 12, 10, 10, + 10, 10, 10, 14, 1, 5, 4, 4, 12, 12, 3, 8, 4, 4, 12, 12, + 2, 2, 6, 4, 12, 12, 2, 2, 2, 6, 12, 12, 10, 10, 10, 10, + 14, 12, 10, 10, 10, 10, 10, 14, 1, 1, 5, 4, 12, 12, 1, 1, + 5, 4, 12, 12, 3, 3, 8, 4, 12, 12, 2, 2, 2, 6, 12, 12, + 10, 10, 10, 10, 14, 12, 10, 10, 10, 10, 10, 14, 1, 1, 1, 5, + 12, 12, 1, 1, 1, 5, 12, 12, 1, 1, 1, 5, 12, 12, 3, 3, + 3, 7, 12, 12, 10, 10, 10, 10, 14, 12, 10, 10, 10, 10, 10, 14, + 9, 9, 9, 9, 13, 12, 9, 9, 9, 9, 13, 12, 9, 9, 9, 9, + 13, 12, 9, 9, 9, 9, 13, 12, 11, 11, 11, 11, 7, 12, 10, 10, + 10, 10, 10, 14, 9, 9, 9, 9, 9, 13, 9, 9, 9, 9, 9, 13, + 9, 9, 9, 9, 9, 13, 9, 9, 9, 9, 9, 13, 9, 9, 9, 9, + 9, 13, 11, 11, 11, 11, 11, 15, 0, 0, 0, 0, 0, 0, 8, 8, + 8, 8, 8, 8, 7, 7, 7, 7, 7, 7, 15, 15, 15, 15, 15, 15 +] + +load_path = [] + +def color_256_to_16(color): + if color == -1: + return color + return table_256_to_16[color] + +def dump_tuple(tup): + """ + Dump a tuple to a string of fg,bg,attr (optional) + """ + return ','.join(str(i) for i in tup) + +def read_tuple(_str): + """ + Read a tuple dumped with dump_tumple + """ + attrs = _str.split(',') + char = attrs[2] if len(attrs) > 2 else None + return (int(attrs[0]), int(attrs[1])), char + +def to_curses_attr(color_tuple): + """ + Takes a color tuple (as defined at the top of this file) and + returns a valid curses attr that can be passed directly to attron() or attroff() + """ + # extract the color from that tuple + if len(color_tuple) == 3: + colors = (color_tuple[0], color_tuple[1]) + else: + colors = color_tuple + + bold = False + if curses.COLORS != 256: + # We are not in a term supporting 256 colors, so we convert + # colors to numbers between -1 and 8 + colors = (color_256_to_16(colors[0]), color_256_to_16(colors[1])) + if colors[0] >= 8: + colors = (colors[0] - 8, colors[1]) + bold = True + if colors[1] >= 8: + colors = (colors[0], colors[1] - 8) + + # check if we already used these colors + try: + pair = curses_colors_dict[colors] + except KeyError: + pair = len(curses_colors_dict) + 1 + curses.init_pair(pair, colors[0], colors[1]) + curses_colors_dict[colors] = pair + curses_pair = curses.color_pair(pair) + if len(color_tuple) == 3: + additional_val = color_tuple[2] + if 'b' in additional_val or bold is True: + curses_pair = curses_pair | curses.A_BOLD + if 'u' in additional_val: + curses_pair = curses_pair | curses.A_UNDERLINE + if 'a' in additional_val: + curses_pair = curses_pair | curses.A_BLINK + return curses_pair + +def get_theme(): + """ + Returns the current theme + """ + return theme + +def update_themes_dir(option=None, value=None): + global load_path + load_path = [] + + # import from the git sources + default_dir = path.join( + path.dirname(path.dirname(__file__)), + 'data/themes') + if path.exists(default_dir): + load_path.append(default_dir) + + # import from the user-defined prefs + themes_dir = path.expanduser( + value or + config.get('themes_dir') or + path.join(os.environ.get('XDG_DATA_HOME') or + path.join(os.environ.get('HOME'), '.local', 'share'), + 'poezio', 'themes') + ) + try: + os.makedirs(themes_dir) + except OSError as e: + if e.errno != 17: + log.error('Unable to create the themes dir (%s)', themes_dir) + else: + load_path.append(themes_dir) + else: + load_path.append(themes_dir) + + # system-wide import + try: + import poezio_themes + except: + pass + else: + if poezio_themes.__path__: + load_path.append(list(poezio_themes.__path__)[0]) + + log.debug('Theme load path: %s', load_path) + +def reload_theme(): + theme_name = config.get('theme') + global theme + if theme_name == 'default' or not theme_name.strip(): + theme = Theme() + return + new_theme = None + exc = None + try: + loader = finder.find_module(theme_name, load_path) + if not loader: + return 'Failed to load the theme %s' % theme_name + new_theme = loader.load_module() + except Exception as e: + log.error('Failed to load the theme %s', theme_name, exc_info=True) + exc = e + + if not new_theme: + return 'Failed to load theme: %s' % exc + + if hasattr(new_theme, 'theme'): + theme = new_theme.theme + else: + return 'No theme present in the theme file' + +if __name__ == '__main__': + # Display some nice text with nice colors + s = curses.initscr() + curses.start_color() + curses.use_default_colors() + s.addstr('%s colors detected\n\n' % curses.COLORS, to_curses_attr((3, -1))) + for i in range(curses.COLORS): + s.addstr('%s ' % i, to_curses_attr((i, -1))) + s.addstr('\n') + s.refresh() + try: + s.getkey() + except KeyboardInterrupt: + pass + finally: + curses.endwin() + print() + diff --git a/poezio/timed_events.py b/poezio/timed_events.py new file mode 100644 index 00000000..7f43d05f --- /dev/null +++ b/poezio/timed_events.py @@ -0,0 +1,58 @@ +# Copyright 2010-2011 Florent Le Coz <louiz@louiz.org> +# +# This file is part of Poezio. +# +# Poezio is free software: you can redistribute it and/or modify +# it under the terms of the zlib license. See the COPYING file. + +""" +Timed events are the standard way to schedule events for later in poezio. + +Once created, they must be added to the list of checked events with +:py:func:`Core.add_timed_event` (within poezio) or with +:py:func:`.PluginAPI.add_timed_event` (within a plugin). +""" + +import asyncio +import logging + +log = logging.getLogger(__name__) + +import datetime + +class DelayedEvent(object): + """ + A TimedEvent, but with the date calculated from now + a delay in seconds. + Use it if you want an event to happen in, e.g. 6 seconds. + """ + def __init__(self, delay, callback, *args): + """ + Create a new DelayedEvent. + + :param int delay: The number of seconds. + :param function callback: The handler that will be executed. + :param \*args: Optional arguments passed to the handler. + """ + self.callback = callback + self.args = args + self.delay = delay + # An asyncio handler, as returned by call_later() or call_at() + self.handler = None + +class TimedEvent(DelayedEvent): + """ + An event with a callback that is called when the specified time is passed. + + The callback and its arguments should be passed as the lasts arguments. + """ + def __init__(self, date, callback, *args): + """ + Create a new timed event. + + :param datetime.datetime date: Time at which the callback must be run. + :param function callback: The handler that will be executed. + :param \*args: Optional arguments passed to the handler. + """ + delta = date - datetime.datetime.now() + delay = delta.total_seconds() + DelayedEvent.__init__(self, delay, callback, *args) diff --git a/poezio/user.py b/poezio/user.py new file mode 100644 index 00000000..4142869b --- /dev/null +++ b/poezio/user.py @@ -0,0 +1,121 @@ +# Copyright 2010-2011 Florent Le Coz <louiz@louiz.org> +# +# This file is part of Poezio. +# +# Poezio is free software: you can redistribute it and/or modify +# it under the terms of the zlib license. See the COPYING file. + +""" +Define the user class. +An user is a MUC participant, not a roster contact (see contact.py) +""" + +from random import choice +from datetime import timedelta, datetime +from hashlib import md5 +import xhtml + +from theming import get_theme + +import logging +log = logging.getLogger(__name__) + +ROLE_DICT = { + '':0, + 'none':0, + 'visitor':1, + 'participant':2, + 'moderator':3 + } + +class User(object): + """ + keep trace of an user in a Room + """ + __slots__ = ('last_talked', 'jid', 'chatstate', 'affiliation', 'show', 'status', 'role', 'nick', 'color') + + def __init__(self, nick, affiliation, show, status, role, jid, deterministic=True, color=''): + self.last_talked = datetime(1, 1, 1) # The oldest possible time + self.update(affiliation, show, status, role) + self.change_nick(nick) + if color != '': + self.change_color(color, deterministic) + else: + if deterministic: + self.set_deterministic_color() + else: + self.color = choice(get_theme().LIST_COLOR_NICKNAMES) + self.jid = jid + self.chatstate = None + + def set_deterministic_color(self): + theme = get_theme() + mod = len(theme.LIST_COLOR_NICKNAMES) + nick_pos = int(md5(self.nick.encode('utf-8')).hexdigest(), 16) % mod + self.color = theme.LIST_COLOR_NICKNAMES[nick_pos] + + def update(self, affiliation, show, status, role): + self.affiliation = affiliation + self.show = show + self.status = status + if role not in ROLE_DICT: # avoid unvalid roles + role = '' + self.role = role + + def change_nick(self, nick): + self.nick = nick + + def change_color(self, color_name, deterministic=False): + color = xhtml.colors.get(color_name) + if color == None: + log.error('Unknown color "%s"' % color_name) + if deterministic: + self.set_deterministic_color() + else: + self.color = choice(get_theme().LIST_COLOR_NICKNAMES) + else: + self.color = (color, -1) + + def set_last_talked(self, time): + """ + time: datetime object + """ + self.last_talked = time + + def has_talked_since(self, t): + """ + t: int + Return True if the user talked since the last s seconds + """ + if self.last_talked is None: + return False + delta = timedelta(0, t) + if datetime.now() - delta > self.last_talked: + return False + return True + + def __repr__(self): + return ">%s<" % (self.nick) + + def __eq__(self, b): + return self.role == b.role and self.nick == b.nick + + def __gt__(self, b): + if ROLE_DICT[self.role] == ROLE_DICT[b.role]: + return self.nick.lower() > b.nick.lower() + return ROLE_DICT[self.role] < ROLE_DICT[b.role] + + def __ge__(self, b): + if ROLE_DICT[self.role] == ROLE_DICT[b.role]: + return self.nick.lower() >= b.nick.lower() + return ROLE_DICT[self.role] <= ROLE_DICT[b.role] + + def __lt__(self, b): + if ROLE_DICT[self.role] == ROLE_DICT[b.role]: + return self.nick.lower() < b.nick.lower() + return ROLE_DICT[self.role] > ROLE_DICT[b.role] + + def __le__(self, b): + if ROLE_DICT[self.role] == ROLE_DICT[b.role]: + return self.nick.lower() <= b.nick.lower() + return ROLE_DICT[self.role] >= ROLE_DICT[b.role] diff --git a/poezio/windows/__init__.py b/poezio/windows/__init__.py new file mode 100644 index 00000000..5ec73961 --- /dev/null +++ b/poezio/windows/__init__.py @@ -0,0 +1,20 @@ +""" +Module exporting all the Windows, which are wrappers around curses wins +used to display information on the screen +""" + +from . base_wins import Win +from . data_forms import FormWin +from . bookmark_forms import BookmarksWin +from . info_bar import GlobalInfoBar, VerticalGlobalInfoBar +from . info_wins import InfoWin, XMLInfoWin, PrivateInfoWin, MucListInfoWin, \ + ConversationInfoWin, DynamicConversationInfoWin, MucInfoWin, \ + ConversationStatusMessageWin, BookmarksInfoWin +from . input_placeholders import HelpText, YesNoInput +from . inputs import Input, HistoryInput, MessageInput, CommandInput +from . list import ListWin, ColumnHeaderWin +from . misc import VerticalSeparator +from . muc import UserList, Topic +from . roster_win import RosterWin, ContactInfoWin +from . text_win import TextWin, XMLTextWin + diff --git a/poezio/windows/base_wins.py b/poezio/windows/base_wins.py new file mode 100644 index 00000000..8df214d2 --- /dev/null +++ b/poezio/windows/base_wins.py @@ -0,0 +1,168 @@ +""" +Define the base window object and the constants/"globals" used +by the file of this module. + +A window is a little part of the screen, for example the input window, +the text window, the roster window, etc. +A Tab (see the poezio.tabs module) is composed of multiple Windows +""" + +import logging +log = logging.getLogger(__name__) + +import collections +import curses +import string + +import core +import singleton +from theming import to_curses_attr, read_tuple + +FORMAT_CHAR = '\x19' +# These are non-printable chars, so they should never appear in the input, +# I guess. But maybe we can find better chars that are even less risky. +format_chars = ['\x0E', '\x0F', '\x10', '\x11', '\x12', '\x13', + '\x14', '\x15', '\x16', '\x17', '\x18'] + +# different colors allowed in the input +allowed_color_digits = ('0', '1', '2', '3', '4', '5', '6', '7') + +# msg is a reference to the corresponding Message tuple. text_start and +# text_end are the position delimiting the text in this line. +Line = collections.namedtuple('Line', 'msg start_pos end_pos prepend') + +LINES_NB_LIMIT = 4096 + +class DummyWin(object): + def __getattribute__(self, name): + if name != '__bool__': + return lambda *args, **kwargs: (0, 0) + else: + return object.__getattribute__(self, name) + + def __bool__(self): + return False + +class Win(object): + _win_core = None + _tab_win = None + def __init__(self): + self._win = None + self.height, self.width = 0, 0 + + def _resize(self, height, width, y, x): + if height == 0 or width == 0: + self.height, self.width = height, width + return + self.height, self.width, self.x, self.y = height, width, x, y + try: + self._win = Win._tab_win.derwin(height, width, y, x) + except: + log.debug('DEBUG: mvwin returned ERR. Please investigate') + if self._win is None: + self._win = DummyWin() + + def resize(self, height, width, y, x): + """ + Override if something has to be done on resize + """ + self._resize(height, width, y, x) + + def _refresh(self): + self._win.noutrefresh() + + def addnstr(self, *args): + """ + Safe call to addnstr + """ + try: + self._win.addnstr(*args) + except: + # this actually mostly returns ERR, but works. + # more specifically, when the added string reaches the end + # of the screen. + pass + + def addstr(self, *args): + """ + Safe call to addstr + """ + try: + self._win.addstr(*args) + except: + pass + + def move(self, y, x): + try: + self._win.move(y, x) + except: + self._win.move(0, 0) + + def addstr_colored(self, text, y=None, x=None): + """ + Write a string on the window, setting the + attributes as they are in the string. + For example: + \x19bhello → hello in bold + \x191}Bonj\x192}our → 'Bonj' in red and 'our' in green + next_attr_char is the \x19 delimiter + attr_char is the char following it, it can be + one of 'u', 'b', 'c[0-9]' + """ + if y is not None and x is not None: + self.move(y, x) + next_attr_char = text.find(FORMAT_CHAR) + while next_attr_char != -1 and text: + if next_attr_char + 1 < len(text): + attr_char = text[next_attr_char+1].lower() + else: + attr_char = str() + if next_attr_char != 0: + self.addstr(text[:next_attr_char]) + if attr_char == 'o': + self._win.attrset(0) + elif attr_char == 'u': + self._win.attron(curses.A_UNDERLINE) + elif attr_char == 'b': + self._win.attron(curses.A_BOLD) + if (attr_char in string.digits or attr_char == '-') and attr_char != '': + color_str = text[next_attr_char+1:text.find('}', next_attr_char)] + if ',' in color_str: + tup, char = read_tuple(color_str) + self._win.attron(to_curses_attr(tup)) + if char: + if char == 'o': + self._win.attrset(0) + elif char == 'u': + self._win.attron(curses.A_UNDERLINE) + elif char == 'b': + self._win.attron(curses.A_BOLD) + else: + # this will reset previous bold/uderline sequences if any was used + self._win.attroff(curses.A_UNDERLINE) + self._win.attroff(curses.A_BOLD) + elif color_str: + self._win.attron(to_curses_attr((int(color_str), -1))) + text = text[next_attr_char+len(color_str)+2:] + else: + text = text[next_attr_char+2:] + next_attr_char = text.find(FORMAT_CHAR) + self.addstr(text) + + def finish_line(self, color=None): + """ + Write colored spaces until the end of line + """ + (y, x) = self._win.getyx() + size = self.width - x + if color: + self.addnstr(' '*size, size, to_curses_attr(color)) + else: + self.addnstr(' '*size, size) + + @property + def core(self): + if not Win._win_core: + Win._win_core = singleton.Singleton(core.Core) + return Win._win_core + diff --git a/poezio/windows/bookmark_forms.py b/poezio/windows/bookmark_forms.py new file mode 100644 index 00000000..de1043c9 --- /dev/null +++ b/poezio/windows/bookmark_forms.py @@ -0,0 +1,278 @@ +""" +Windows used inthe bookmarkstab +""" +import curses + +from . import Win +from . inputs import Input +from . data_forms import FieldInput +from theming import to_curses_attr, get_theme +from common import safeJID + +class BookmarkJIDInput(FieldInput, Input): + def __init__(self, field): + FieldInput.__init__(self, field) + Input.__init__(self) + jid = safeJID(field.jid) + jid.resource = field.nick or None + self.text = jid.full + self.pos = len(self.text) + self.color = get_theme().COLOR_NORMAL_TEXT + + def save(self): + jid = safeJID(self.get_text()) + self._field.jid = jid.bare + self._field.name = jid.bare + self._field.nick = jid.resource + + def get_help_message(self): + return 'Edit the text' + +class BookmarkMethodInput(FieldInput, Win): + def __init__(self, field): + FieldInput.__init__(self, field) + Win.__init__(self) + self.options = ('local', 'remote') + # val_pos is the position of the currently selected option + self.val_pos = self.options.index(field.method) + + def do_command(self, key): + if key == 'KEY_LEFT': + if self.val_pos > 0: + self.val_pos -= 1 + elif key == 'KEY_RIGHT': + if self.val_pos < len(self.options)-1: + self.val_pos += 1 + else: + return + self.refresh() + + def refresh(self): + self._win.erase() + self._win.attron(to_curses_attr(self.color)) + self.addnstr(0, 0, ' '*self.width, self.width) + if self.val_pos > 0: + self.addstr(0, 0, '←') + if self.val_pos < len(self.options)-1: + self.addstr(0, self.width-1, '→') + if self.options: + option = self.options[self.val_pos] + self.addstr(0, self.width//2-len(option)//2, option) + self._win.attroff(to_curses_attr(self.color)) + self._refresh() + + def save(self): + self._field.method = self.options[self.val_pos] + + def get_help_message(self): + return '←, →: Select a value amongst the others' + +class BookmarkPasswordInput(FieldInput, Input): + def __init__(self, field): + FieldInput.__init__(self, field) + Input.__init__(self) + self.text = field.password or '' + self.pos = len(self.text) + self.color = get_theme().COLOR_NORMAL_TEXT + + def rewrite_text(self): + self._win.erase() + if self.color: + self._win.attron(to_curses_attr(self.color)) + self.addstr('*'*len(self.text[self.view_pos:self.view_pos+self.width-1])) + if self.color: + (y, x) = self._win.getyx() + size = self.width-x + self.addnstr(' '*size, size, to_curses_attr(self.color)) + self.addstr(0, self.pos, '') + if self.color: + self._win.attroff(to_curses_attr(self.color)) + self._refresh() + + def save(self): + self._field.password = self.get_text() or None + + def get_help_message(self): + return 'Edit the secret text' + +class BookmarkAutojoinWin(FieldInput, Win): + def __init__(self, field): + FieldInput.__init__(self, field) + Win.__init__(self) + self.last_key = 'KEY_RIGHT' + self.value = field.autojoin + + def do_command(self, key): + if key == 'KEY_LEFT' or key == 'KEY_RIGHT': + self.value = not self.value + self.last_key = key + self.refresh() + + def refresh(self): + self._win.erase() + self._win.attron(to_curses_attr(self.color)) + format_string = '←{:^%s}→' % 7 + inp = format_string.format(repr(self.value)) + self.addstr(0, 0, inp) + if self.last_key == 'KEY_RIGHT': + self.move(0, 8) + else: + self.move(0, 0) + self._win.attroff(to_curses_attr(self.color)) + self._refresh() + + def save(self): + self._field.autojoin = self.value + + def get_help_message(self): + return '← and →: change the value between True and False' + + +class BookmarksWin(Win): + def __init__(self, bookmarks, height, width, y, x): + self._win = Win._tab_win.derwin(height, width, y, x) + self.scroll_pos = 0 + self._current_input = 0 + self.current_horizontal_input = 0 + self._bookmarks = list(bookmarks) + self.lines = [] + for bookmark in sorted(self._bookmarks, key=lambda x: x.jid): + self.lines.append((BookmarkJIDInput(bookmark), + BookmarkPasswordInput(bookmark), + BookmarkAutojoinWin(bookmark), + BookmarkMethodInput(bookmark))) + + @property + def current_input(self): + return self._current_input + + @current_input.setter + def current_input(self, value): + if 0 <= self._current_input < len(self.lines): + if 0 <= value < len(self.lines): + self.lines[self._current_input][self.current_horizontal_input].set_color(get_theme().COLOR_NORMAL_TEXT) + self._current_input = value + else: + self._current_input = 0 + + def add_bookmark(self, bookmark): + self.lines.append((BookmarkJIDInput(bookmark), + BookmarkPasswordInput(bookmark), + BookmarkAutojoinWin(bookmark), + BookmarkMethodInput(bookmark))) + self.lines[self.current_input][self.current_horizontal_input].set_color(get_theme().COLOR_NORMAL_TEXT) + self.current_horizontal_input = 0 + self.current_input = len(self.lines) - 1 + if self.current_input - self.scroll_pos > self.height-1: + self.scroll_pos = self.current_input - self.height + 1 + self.refresh() + + def del_current_bookmark(self): + if self.lines: + bm = self.lines[self.current_input][0]._field + to_delete = self.current_input + self.current_input -= 1 + del self.lines[to_delete] + if self.scroll_pos: + self.scroll_pos -= 1 + self.refresh() + return bm + + def resize(self, height, width, y, x): + self.height = height + self.width = width + self._win = Win._tab_win.derwin(height, width, y, x) + # Adjust the scroll position, if resizing made the window too small + # for the cursor to be visible + while self.current_input - self.scroll_pos > self.height-1: + self.scroll_pos += 1 + + def go_to_next_line_input(self): + if not self.lines: + return + if self.current_input == len(self.lines) - 1: + return + self.lines[self.current_input][self.current_horizontal_input].set_color(get_theme().COLOR_NORMAL_TEXT) + # Adjust the scroll position if the current_input would be outside + # of the visible area + if self.current_input + 1 - self.scroll_pos > self.height-1: + self.current_input += 1 + self.scroll_pos += 1 + self.refresh() + else: + self.current_input += 1 + self.lines[self.current_input][self.current_horizontal_input].set_color(get_theme().COLOR_SELECTED_ROW) + + def go_to_previous_line_input(self): + if not self.lines: + return + if self.current_input == 0: + return + self.lines[self.current_input][self.current_horizontal_input].set_color(get_theme().COLOR_NORMAL_TEXT) + self.current_input -= 1 + # Adjust the scroll position if the current_input would be outside + # of the visible area + if self.current_input < self.scroll_pos: + self.scroll_pos = self.current_input + self.refresh() + self.lines[self.current_input][self.current_horizontal_input].set_color(get_theme().COLOR_SELECTED_ROW) + + def go_to_next_horizontal_input(self): + if not self.lines: + return + self.lines[self.current_input][self.current_horizontal_input].set_color(get_theme().COLOR_NORMAL_TEXT) + self.current_horizontal_input += 1 + if self.current_horizontal_input > 3: + self.current_horizontal_input = 0 + self.lines[self.current_input][self.current_horizontal_input].set_color(get_theme().COLOR_SELECTED_ROW) + + def go_to_previous_horizontal_input(self): + if not self.lines: + return + if self.current_horizontal_input == 0: + return + self.lines[self.current_input][self.current_horizontal_input].set_color(get_theme().COLOR_NORMAL_TEXT) + self.current_horizontal_input -= 1 + self.lines[self.current_input][self.current_horizontal_input].set_color(get_theme().COLOR_SELECTED_ROW) + + def on_input(self, key): + if not self.lines: + return + self.lines[self.current_input][self.current_horizontal_input].do_command(key) + + def refresh(self): + # store the cursor status + self._win.erase() + y = - self.scroll_pos + for i in range(len(self.lines)): + self.lines[i][0].resize(1, self.width//3, y + 1, 0) + self.lines[i][1].resize(1, self.width//3, y + 1, self.width//3) + self.lines[i][2].resize(1, self.width//6, y + 1, 2*self.width//3) + self.lines[i][3].resize(1, self.width//6, y + 1, 5*self.width//6) + y += 1 + self._refresh() + for i, inp in enumerate(self.lines): + if i < self.scroll_pos: + continue + if i >= self.height + self.scroll_pos: + break + for j in range(4): + inp[j].refresh() + + if self.lines and self.current_input < self.height-1: + self.lines[self.current_input][self.current_horizontal_input].set_color(get_theme().COLOR_SELECTED_ROW) + self.lines[self.current_input][self.current_horizontal_input].refresh() + if not self.lines: + curses.curs_set(0) + else: + curses.curs_set(1) + + def refresh_current_input(self): + if self.lines: + self.lines[self.current_input][self.current_horizontal_input].refresh() + + def save(self): + for line in self.lines: + for item in line: + item.save() + diff --git a/poezio/windows/data_forms.py b/poezio/windows/data_forms.py new file mode 100644 index 00000000..410648ec --- /dev/null +++ b/poezio/windows/data_forms.py @@ -0,0 +1,472 @@ +""" +Windows used by the DataFormsTab. + +We only need to export the FormWin (which is not a real Win, as it +does not inherit from the Win base class), as it will create the +others when needed. +""" + +from . import Win +from . inputs import Input + +from theming import to_curses_attr, get_theme + +class FieldInput(object): + """ + All input type in a data form should inherite this class, + in addition with windows.Input or any relevant class from the + 'windows' library. + """ + def __init__(self, field): + self._field = field + self.color = get_theme().COLOR_NORMAL_TEXT + + def set_color(self, color): + self.color = color + self.refresh() + + def update_field_value(self, value): + raise NotImplementedError + + def resize(self, height, width, y, x): + self._resize(height, width, y, x) + + def is_dummy(self): + return False + + def reply(self): + """ + Set the correct response value in the field + """ + raise NotImplementedError + + def get_help_message(self): + """ + Should return a string explaining the keys of the input. + Will be displayed at each refresh on a line at the bottom of the tab. + """ + return '' + +class ColoredLabel(Win): + def __init__(self, text): + self.text = text + self.color = get_theme().COLOR_NORMAL_TEXT + Win.__init__(self) + + def resize(self, height, width, y, x): + self._resize(height, width, y, x) + + def set_color(self, color): + self.color = color + self.refresh() + + def refresh(self): + self._win.erase() + self._win.attron(to_curses_attr(self.color)) + self.addstr(0, 0, self.text) + self._win.attroff(to_curses_attr(self.color)) + self._refresh() + + +class DummyInput(FieldInput, Win): + """ + Used for fields that do not require any input ('fixed') + """ + def __init__(self, field): + FieldInput.__init__(self, field) + Win.__init__(self) + + def do_command(self): + return + + def refresh(self): + return + + def is_dummy(self): + return True + +class BooleanWin(FieldInput, Win): + def __init__(self, field): + FieldInput.__init__(self, field) + Win.__init__(self) + self.last_key = 'KEY_RIGHT' + self.value = bool(field.getValue()) + + def do_command(self, key): + if key == 'KEY_LEFT' or key == 'KEY_RIGHT': + self.value = not self.value + self.last_key = key + self.refresh() + + def refresh(self): + self._win.erase() + self._win.attron(to_curses_attr(self.color)) + self.addnstr(0, 0, ' '*(8), self.width) + self.addstr(0, 2, "%s"%self.value) + self.addstr(0, 8, '→') + self.addstr(0, 0, '←') + if self.last_key == 'KEY_RIGHT': + self.addstr(0, 8, '') + else: + self.addstr(0, 0, '') + self._win.attroff(to_curses_attr(self.color)) + self._refresh() + + def reply(self): + self._field['label'] = '' + self._field.setAnswer(self.value) + + def get_help_message(self): + return '← and →: change the value between True and False' + +class TextMultiWin(FieldInput, Win): + def __init__(self, field): + FieldInput.__init__(self, field) + Win.__init__(self) + self.options = field.getValue() + if not isinstance(self.options, list): + self.options = self.options.split('\n') if self.options else [] + self.val_pos = 0 + self.edition_input = None + if not isinstance(self.options, list): + if isinstance(self.options, str): + self.options = [self.options] + else: + self.options = [] + self.options.append('') + + def do_command(self, key): + if not self.edition_input: + if key == 'KEY_LEFT': + if self.val_pos > 0: + self.val_pos -= 1 + elif key == 'KEY_RIGHT': + if self.val_pos < len(self.options)-1: + self.val_pos += 1 + elif key == '^M': + self.edition_input = Input() + self.edition_input.color = self.color + self.edition_input.resize(self.height, self.width, self.y, self.x) + self.edition_input.text = self.options[self.val_pos] + self.edition_input.key_end() + else: + if key == '^M': + self.options[self.val_pos] = self.edition_input.get_text() + if not self.options[self.val_pos] and self.val_pos != len(self.options) -1: + del self.options[self.val_pos] + if self.val_pos == len(self.options) -1: + self.val_pos -= 1 + self.edition_input = None + if not self.options or self.options[-1] != '': + self.options.append('') + else: + self.edition_input.do_command(key) + self.refresh() + + def refresh(self): + if not self.edition_input: + self._win.erase() + self._win.attron(to_curses_attr(self.color)) + self.addnstr(0, 0, ' '*self.width, self.width) + option = self.options[self.val_pos] + self.addstr(0, self.width//2-len(option)//2, option) + if self.val_pos > 0: + self.addstr(0, 0, '←') + if self.val_pos < len(self.options)-1: + self.addstr(0, self.width-1, '→') + self._win.attroff(to_curses_attr(self.color)) + self._refresh() + else: + self.edition_input.refresh() + + def reply(self): + values = [val for val in self.options if val] + self._field.setAnswer(values) + + def get_help_message(self): + if not self.edition_input: + help_msg = '← and →: browse the available entries. ' + if self.val_pos == len(self.options)-1: + help_msg += 'Enter: add an entry' + else: + help_msg += 'Enter: edit this entry' + else: + help_msg = 'Enter: finish editing this entry.' + return help_msg + +class ListMultiWin(FieldInput, Win): + def __init__(self, field): + FieldInput.__init__(self, field) + Win.__init__(self) + values = field.getValue() or [] + self.options = [[option, True if option['value'] in values else False]\ + for option in field.get_options()] + self.val_pos = 0 + + def do_command(self, key): + if key == 'KEY_LEFT': + if self.val_pos > 0: + self.val_pos -= 1 + elif key == 'KEY_RIGHT': + if self.val_pos < len(self.options)-1: + self.val_pos += 1 + elif key == ' ': + self.options[self.val_pos][1] = not self.options[self.val_pos][1] + else: + return + self.refresh() + + def refresh(self): + self._win.erase() + self._win.attron(to_curses_attr(self.color)) + self.addnstr(0, 0, ' '*self.width, self.width) + if self.val_pos > 0: + self.addstr(0, 0, '←') + if self.val_pos < len(self.options)-1: + self.addstr(0, self.width-1, '→') + if self.options: + option = self.options[self.val_pos] + self.addstr(0, self.width//2-len(option)//2, option[0]['label']) + self.addstr(0, 2, '✔' if option[1] else '☐') + self._win.attroff(to_curses_attr(self.color)) + self._refresh() + + def reply(self): + self._field['label'] = '' + self._field.delOptions() + values = [option[0]['value'] for option in self.options if option[1] is True] + self._field.setAnswer(values) + + def get_help_message(self): + return '←, →: Switch between the value. Space: select or unselect a value' + +class ListSingleWin(FieldInput, Win): + def __init__(self, field): + FieldInput.__init__(self, field) + Win.__init__(self) + # the option list never changes + self.options = field.getOptions() + # val_pos is the position of the currently selected option + self.val_pos = 0 + for i, option in enumerate(self.options): + if field.getValue() == option['value']: + self.val_pos = i + + def do_command(self, key): + if key == 'KEY_LEFT': + if self.val_pos > 0: + self.val_pos -= 1 + elif key == 'KEY_RIGHT': + if self.val_pos < len(self.options)-1: + self.val_pos += 1 + else: + return + self.refresh() + + def refresh(self): + self._win.erase() + self._win.attron(to_curses_attr(self.color)) + self.addnstr(0, 0, ' '*self.width, self.width) + if self.val_pos > 0: + self.addstr(0, 0, '←') + if self.val_pos < len(self.options)-1: + self.addstr(0, self.width-1, '→') + if self.options: + option = self.options[self.val_pos]['label'] + self.addstr(0, self.width//2-len(option)//2, option) + self._win.attroff(to_curses_attr(self.color)) + self._refresh() + + def reply(self): + self._field['label'] = '' + self._field.delOptions() + self._field.setAnswer(self.options[self.val_pos]['value']) + + def get_help_message(self): + return '←, →: Select a value amongst the others' + +class TextSingleWin(FieldInput, Input): + def __init__(self, field): + FieldInput.__init__(self, field) + Input.__init__(self) + self.text = field.getValue() if isinstance(field.getValue(), str)\ + else "" + self.pos = len(self.text) + self.color = get_theme().COLOR_NORMAL_TEXT + + def reply(self): + self._field['label'] = '' + self._field.setAnswer(self.get_text()) + + def get_help_message(self): + return 'Edit the text' + +class TextPrivateWin(TextSingleWin): + def __init__(self, field): + TextSingleWin.__init__(self, field) + + def rewrite_text(self): + self._win.erase() + if self.color: + self._win.attron(to_curses_attr(self.color)) + self.addstr('*'*len(self.text[self.view_pos:self.view_pos+self.width-1])) + if self.color: + (y, x) = self._win.getyx() + size = self.width-x + self.addnstr(' '*size, size, to_curses_attr(self.color)) + self.addstr(0, self.pos, '') + if self.color: + self._win.attroff(to_curses_attr(self.color)) + self._refresh() + + def get_help_message(self): + return 'Edit the secret text' + +class FormWin(object): + """ + A window, with some subwins (the various inputs). + On init, create all the subwins. + On resize, move and resize all the subwin and define how the text will be written + On refresh, write all the text, and refresh all the subwins + """ + input_classes = {'boolean': BooleanWin, + 'fixed': DummyInput, + 'jid-multi': TextMultiWin, + 'jid-single': TextSingleWin, + 'list-multi': ListMultiWin, + 'list-single': ListSingleWin, + 'text-multi': TextMultiWin, + 'text-private': TextPrivateWin, + 'text-single': TextSingleWin, + } + def __init__(self, form, height, width, y, x): + self._form = form + self._win = Win._tab_win.derwin(height, width, y, x) + self.scroll_pos = 0 + self.current_input = 0 + self.inputs = [] # dict list + for (name, field) in self._form.getFields().items(): + if field['type'] == 'hidden': + continue + try: + input_class = self.input_classes[field['type']] + except IndexError: + continue + label = field['label'] + desc = field['desc'] + if field['type'] == 'fixed': + label = field.getValue() + inp = input_class(field) + self.inputs.append({'label':ColoredLabel(label), + 'description': desc, + 'input':inp}) + + def resize(self, height, width, y, x): + self.height = height + self.width = width + self._win = Win._tab_win.derwin(height, width, y, x) + # Adjust the scroll position, if resizing made the window too small + # for the cursor to be visible + while self.current_input - self.scroll_pos > self.height-1: + self.scroll_pos += 1 + + def reply(self): + """ + Set the response values in the form, for each field + from the corresponding input + """ + for inp in self.inputs: + if inp['input'].is_dummy(): + continue + else: + inp['input'].reply() + self._form['title'] = '' + self._form['instructions'] = '' + + def go_to_next_input(self): + if not self.inputs: + return + if self.current_input == len(self.inputs) - 1: + return + self.inputs[self.current_input]['input'].set_color(get_theme().COLOR_NORMAL_TEXT) + self.inputs[self.current_input]['label'].set_color(get_theme().COLOR_NORMAL_TEXT) + self.current_input += 1 + jump = 0 + while self.current_input+jump != len(self.inputs) - 1 and self.inputs[self.current_input+jump]['input'].is_dummy(): + jump += 1 + if self.inputs[self.current_input+jump]['input'].is_dummy(): + return + self.current_input += jump + # If moving made the current input out of the visible screen, we + # adjust the scroll position and we redraw the whole thing. We don’t + # call refresh() if this is not the case, because + # refresh_current_input() is always called anyway, so this is not + # needed + if self.current_input - self.scroll_pos > self.height-1: + self.scroll_pos += 1 + self.refresh() + self.inputs[self.current_input]['input'].set_color(get_theme().COLOR_SELECTED_ROW) + self.inputs[self.current_input]['label'].set_color(get_theme().COLOR_SELECTED_ROW) + + def go_to_previous_input(self): + if not self.inputs: + return + if self.current_input == 0: + return + self.inputs[self.current_input]['input'].set_color(get_theme().COLOR_NORMAL_TEXT) + self.inputs[self.current_input]['label'].set_color(get_theme().COLOR_NORMAL_TEXT) + self.current_input -= 1 + jump = 0 + while self.current_input-jump > 0 and self.inputs[self.current_input+jump]['input'].is_dummy(): + jump += 1 + if self.inputs[self.current_input+jump]['input'].is_dummy(): + return + # Adjust the scroll position if the current_input would be outside + # of the visible area + if self.current_input < self.scroll_pos: + self.scroll_pos = self.current_input + self.refresh() + self.current_input -= jump + self.inputs[self.current_input]['input'].set_color(get_theme().COLOR_SELECTED_ROW) + self.inputs[self.current_input]['label'].set_color(get_theme().COLOR_SELECTED_ROW) + + def on_input(self, key): + if not self.inputs: + return + self.inputs[self.current_input]['input'].do_command(key) + + def refresh(self): + self._win.erase() + y = -self.scroll_pos + i = 0 + for name, field in self._form.getFields().items(): + if field['type'] == 'hidden': + continue + self.inputs[i]['label'].resize(1, self.width//2, y + 1, 0) + self.inputs[i]['input'].resize(1, self.width//2, y+1, self.width//2) + # TODO: display the field description + y += 1 + i += 1 + self._win.refresh() + for i, inp in enumerate(self.inputs): + if i < self.scroll_pos: + continue + if i >= self.height + self.scroll_pos: + break + inp['label'].refresh() + inp['input'].refresh() + inp['label'].refresh() + if self.inputs and self.current_input < self.height-1: + self.inputs[self.current_input]['input'].set_color(get_theme().COLOR_SELECTED_ROW) + self.inputs[self.current_input]['input'].refresh() + self.inputs[self.current_input]['label'].set_color(get_theme().COLOR_SELECTED_ROW) + self.inputs[self.current_input]['label'].refresh() + + def refresh_current_input(self): + self.inputs[self.current_input]['input'].refresh() + + def get_help_message(self): + if self.inputs and self.current_input < self.height-1 and self.inputs[self.current_input]['input']: + return self.inputs[self.current_input]['input'].get_help_message() + return '' + diff --git a/poezio/windows/funcs.py b/poezio/windows/funcs.py new file mode 100644 index 00000000..f1401628 --- /dev/null +++ b/poezio/windows/funcs.py @@ -0,0 +1,54 @@ +""" +Standalone functions used by the modules +""" + +import string + +from . base_wins import FORMAT_CHAR, format_chars + +def find_first_format_char(text, chars=None): + if chars is None: + chars = format_chars + pos = -1 + for char in chars: + p = text.find(char) + if p == -1: + continue + if pos == -1 or p < pos: + pos = p + return pos + +def truncate_nick(nick, size=10): + if size < 1: + size = 1 + if nick and len(nick) > size: + return nick[:size]+'…' + return nick + +def parse_attrs(text, previous=None): + next_attr_char = text.find(FORMAT_CHAR) + if previous: + attrs = previous + else: + attrs = [] + while next_attr_char != -1 and text: + if next_attr_char + 1 < len(text): + attr_char = text[next_attr_char+1].lower() + else: + attr_char = str() + if attr_char == 'o': + attrs = [] + elif attr_char == 'u': + attrs.append('u') + elif attr_char == 'b': + attrs.append('b') + if attr_char in string.digits and attr_char != '': + color_str = text[next_attr_char+1:text.find('}', next_attr_char)] + if color_str: + attrs.append(color_str + '}') + text = text[next_attr_char+len(color_str)+2:] + else: + text = text[next_attr_char+2:] + next_attr_char = text.find(FORMAT_CHAR) + return attrs + diff --git a/poezio/windows/info_bar.py b/poezio/windows/info_bar.py new file mode 100644 index 00000000..abd956cd --- /dev/null +++ b/poezio/windows/info_bar.py @@ -0,0 +1,106 @@ +""" +Module defining the global info bar + +This window is the one listing the current opened tabs in poezio. +The GlobalInfoBar can be either horizontal or vertical +(VerticalGlobalInfoBar). +""" +import logging +log = logging.getLogger(__name__) + +import curses + + +from config import config +from . import Win +from theming import get_theme, to_curses_attr + +class GlobalInfoBar(Win): + def __init__(self): + Win.__init__(self) + + def refresh(self): + log.debug('Refresh: %s', self.__class__.__name__) + self._win.erase() + self.addstr(0, 0, "[", to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) + + create_gaps = config.get('create_gaps') + show_names = config.get('show_tab_names') + show_nums = config.get('show_tab_numbers') + use_nicks = config.get('use_tab_nicks') + show_inactive = config.get('show_inactive_tabs') + # ignore any remaining gap tabs if the feature is not enabled + if create_gaps: + sorted_tabs = self.core.tabs[:] + else: + sorted_tabs = [tab for tab in self.core.tabs if tab] + + for nb, tab in enumerate(sorted_tabs): + if not tab: continue + color = tab.color + if not show_inactive and color is get_theme().COLOR_TAB_NORMAL: + continue + try: + if show_nums or not show_names: + self.addstr("%s" % str(nb), to_curses_attr(color)) + if show_names: + self.addstr(' ', to_curses_attr(color)) + if show_names: + if use_nicks: + self.addstr("%s" % str(tab.get_nick()), to_curses_attr(color)) + else: + self.addstr("%s" % tab.name, to_curses_attr(color)) + self.addstr("|", to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) + except: # end of line + break + (y, x) = self._win.getyx() + self.addstr(y, x-1, '] ', to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) + (y, x) = self._win.getyx() + remaining_size = self.width - x + self.addnstr(' '*remaining_size, remaining_size, + to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) + self._refresh() + +class VerticalGlobalInfoBar(Win): + def __init__(self, scr): + Win.__init__(self) + self._win = scr + + def refresh(self): + height, width = self._win.getmaxyx() + self._win.erase() + sorted_tabs = [tab for tab in self.core.tabs if tab] + if not config.get('show_inactive_tabs'): + sorted_tabs = [tab for tab in sorted_tabs if\ + tab.vertical_color != get_theme().COLOR_VERTICAL_TAB_NORMAL] + nb_tabs = len(sorted_tabs) + use_nicks = config.get('use_tab_nicks') + if nb_tabs >= height: + for y, tab in enumerate(sorted_tabs): + if tab.vertical_color == get_theme().COLOR_VERTICAL_TAB_CURRENT: + pos = y + break + # center the current tab as much as possible + if pos < height//2: + sorted_tabs = sorted_tabs[:height] + elif nb_tabs - pos <= height//2: + sorted_tabs = sorted_tabs[-height:] + else: + sorted_tabs = sorted_tabs[pos-height//2 : pos+height//2] + asc_sort = (config.get('vertical_tab_list_sort') == 'asc') + for y, tab in enumerate(sorted_tabs): + color = tab.vertical_color + if asc_sort: + y = height - y - 1 + self.addstr(y, 0, "%2d" % tab.nb, + to_curses_attr(get_theme().COLOR_VERTICAL_TAB_NUMBER)) + self.addstr('.') + if use_nicks: + self.addnstr("%s" % tab.get_nick(), width - 4, to_curses_attr(color)) + else: + self.addnstr("%s" % tab.name, width - 4, to_curses_attr(color)) + separator = to_curses_attr(get_theme().COLOR_VERTICAL_SEPARATOR) + self._win.attron(separator) + self._win.vline(0, width-1, curses.ACS_VLINE, height) + self._win.attroff(separator) + self._refresh() diff --git a/poezio/windows/info_wins.py b/poezio/windows/info_wins.py new file mode 100644 index 00000000..f6aebd35 --- /dev/null +++ b/poezio/windows/info_wins.py @@ -0,0 +1,311 @@ +""" +Module defining all the "info wins", ie the bar which is on top of the +info buffer in normal tabs +""" + +import logging +log = logging.getLogger(__name__) + +from common import safeJID +from config import config + +from . import Win +from . funcs import truncate_nick +from theming import get_theme, to_curses_attr + +class InfoWin(Win): + """ + Base class for all the *InfoWin, used in various tabs. For example + MucInfoWin, etc. Provides some useful methods. + """ + def __init__(self): + Win.__init__(self) + + def print_scroll_position(self, window): + """ + Print, like in Weechat, a -MORE(n)- where n + is the number of available lines to scroll + down + """ + if window.pos > 0: + plus = ' -MORE(%s)-' % window.pos + self.addstr(plus, to_curses_attr(get_theme().COLOR_SCROLLABLE_NUMBER)) + +class XMLInfoWin(InfoWin): + """ + Info about the latest xml filter used and the state of the buffer. + """ + def __init__(self): + InfoWin.__init__(self) + + def refresh(self, filter_t='', filter='', window=None): + log.debug('Refresh: %s', self.__class__.__name__) + self._win.erase() + bar = to_curses_attr(get_theme().COLOR_INFORMATION_BAR) + if not filter_t: + self.addstr('[No filter]', bar) + else: + info = '[%s] %s' % (filter_t, filter) + self.addstr(info, bar) + self.print_scroll_position(window) + self.finish_line(get_theme().COLOR_INFORMATION_BAR) + self._refresh() + +class PrivateInfoWin(InfoWin): + """ + The line above the information window, displaying informations + about the MUC user we are talking to + """ + def __init__(self): + InfoWin.__init__(self) + + def refresh(self, name, window, chatstate, informations): + log.debug('Refresh: %s', self.__class__.__name__) + self._win.erase() + self.write_room_name(name) + self.print_scroll_position(window) + self.write_chatstate(chatstate) + self.write_additional_informations(informations, name) + self.finish_line(get_theme().COLOR_INFORMATION_BAR) + self._refresh() + + def write_additional_informations(self, informations, jid): + """ + Write all informations added by plugins by getting the + value returned by the callbacks. + """ + for key in informations: + self.addstr(informations[key](jid), to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) + + def write_room_name(self, name): + jid = safeJID(name) + room_name, nick = jid.bare, jid.resource + self.addstr(nick, to_curses_attr(get_theme().COLOR_PRIVATE_NAME)) + txt = ' from room %s' % room_name + self.addstr(txt, to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) + + def write_chatstate(self, state): + if state: + self.addstr(' %s' % (state,), to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) + +class MucListInfoWin(InfoWin): + """ + The live above the information window, displaying informations + about the muc server being listed + """ + def __init__(self, message=''): + InfoWin.__init__(self) + self.message = message + + def refresh(self, name=None, window=None): + log.debug('Refresh: %s', self.__class__.__name__) + self._win.erase() + if name: + self.addstr(name, to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) + else: + self.addstr(self.message, to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) + if window: + self.print_scroll_position(window) + self.finish_line(get_theme().COLOR_INFORMATION_BAR) + self._refresh() + +class ConversationInfoWin(InfoWin): + """ + The line above the information window, displaying informations + about the user we are talking to + """ + + def __init__(self): + InfoWin.__init__(self) + + def refresh(self, jid, contact, window, chatstate, informations): + # contact can be None, if we receive a message + # from someone not in our roster. In this case, we display + # only the maximum information from the message we can get. + log.debug('Refresh: %s', self.__class__.__name__) + jid = safeJID(jid) + if contact: + if jid.resource: + resource = contact[jid.full] + else: + resource = contact.get_highest_priority_resource() + else: + resource = None + # if contact is None, then resource is None too: + # user is not in the roster so we know almost nothing about it + # If contact is a Contact, then + # resource can now be a Resource: user is in the roster and online + # or resource is None: user is in the roster but offline + self._win.erase() + if config.get('show_jid_in_conversations'): + self.write_contact_jid(jid) + self.write_contact_informations(contact) + self.write_resource_information(resource) + self.print_scroll_position(window) + self.write_chatstate(chatstate) + self.write_additional_informations(informations, jid) + self.finish_line(get_theme().COLOR_INFORMATION_BAR) + self._refresh() + + def write_additional_informations(self, informations, jid): + """ + Write all informations added by plugins by getting the + value returned by the callbacks. + """ + for key in informations: + self.addstr(informations[key](jid), + to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) + + def write_resource_information(self, resource): + """ + Write the informations about the resource + """ + if not resource: + presence = "unavailable" + else: + presence = resource.presence + color = get_theme().color_show(presence) + self.addstr('[', to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) + self.addstr(get_theme().CHAR_STATUS, to_curses_attr(color)) + self.addstr(']', to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) + + def write_contact_informations(self, contact): + """ + Write the informations about the contact + """ + if not contact: + self.addstr("(contact not in roster)", to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) + return + display_name = contact.name + if display_name: + self.addstr('%s '%(display_name), to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) + + def write_contact_jid(self, jid): + """ + Just write the jid that we are talking to + """ + self.addstr('[', to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) + self.addstr(jid.full, to_curses_attr(get_theme().COLOR_CONVERSATION_NAME)) + self.addstr('] ', to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) + + def write_chatstate(self, state): + if state: + self.addstr(' %s' % (state,), to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) + +class DynamicConversationInfoWin(ConversationInfoWin): + def write_contact_jid(self, jid): + """ + Just displays the resource in an other color + """ + log.debug("write_contact_jid DynamicConversationInfoWin, jid: %s", + jid.resource) + self.addstr('[', to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) + self.addstr(jid.bare, to_curses_attr(get_theme().COLOR_CONVERSATION_NAME)) + if jid.resource: + self.addstr("/%s" % (jid.resource,), to_curses_attr(get_theme().COLOR_CONVERSATION_RESOURCE)) + self.addstr('] ', to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) + +class MucInfoWin(InfoWin): + """ + The line just above the information window, displaying informations + about the MUC we are viewing + """ + def __init__(self): + InfoWin.__init__(self) + + def refresh(self, room, window=None): + log.debug('Refresh: %s', self.__class__.__name__) + self._win.erase() + self.write_room_name(room) + self.write_participants_number(room) + self.write_own_nick(room) + self.write_disconnected(room) + self.write_role(room) + if window: + self.print_scroll_position(window) + self.finish_line(get_theme().COLOR_INFORMATION_BAR) + self._refresh() + + def write_room_name(self, room): + self.addstr('[', to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) + self.addstr(room.name, to_curses_attr(get_theme().COLOR_GROUPCHAT_NAME)) + self.addstr(']', to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) + + def write_participants_number(self, room): + self.addstr('{', to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) + self.addstr(str(len(room.users)), to_curses_attr(get_theme().COLOR_GROUPCHAT_NAME)) + self.addstr('} ', to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) + + def write_disconnected(self, room): + """ + Shows a message if the room is not joined + """ + if not room.joined: + self.addstr(' -!- Not connected ', to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) + + def write_own_nick(self, room): + """ + Write our own nick in the info bar + """ + nick = room.own_nick + if not nick: + return + self.addstr(truncate_nick(nick, 13), to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) + + def write_role(self, room): + """ + Write our own role and affiliation + """ + own_user = None + for user in room.users: + if user.nick == room.own_nick: + own_user = user + break + if not own_user: + return + txt = ' (' + if own_user.affiliation != 'none': + txt += own_user.affiliation+', ' + txt += own_user.role+')' + self.addstr(txt, to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) + +class ConversationStatusMessageWin(InfoWin): + """ + The upper bar displaying the status message of the contact + """ + def __init__(self): + InfoWin.__init__(self) + + def refresh(self, jid, contact): + log.debug('Refresh: %s', self.__class__.__name__) + jid = safeJID(jid) + if contact: + if jid.resource: + resource = contact[jid.full] + else: + resource = contact.get_highest_priority_resource() + else: + resource = None + self._win.erase() + if resource: + self.write_status_message(resource) + self.finish_line(get_theme().COLOR_INFORMATION_BAR) + self._refresh() + + def write_status_message(self, resource): + self.addstr(resource.status, to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) + +class BookmarksInfoWin(InfoWin): + def __init__(self): + InfoWin.__init__(self) + + def refresh(self, preferred): + log.debug('Refresh: %s', self.__class__.__name__) + self._win.erase() + self.write_remote_status(preferred) + self.finish_line(get_theme().COLOR_INFORMATION_BAR) + self._refresh() + + def write_remote_status(self, preferred): + self.addstr('Remote storage: %s' % preferred, to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) + diff --git a/poezio/windows/input_placeholders.py b/poezio/windows/input_placeholders.py new file mode 100644 index 00000000..496417d1 --- /dev/null +++ b/poezio/windows/input_placeholders.py @@ -0,0 +1,77 @@ +""" +Classes used to replace the input in some tabs or special situations, +but which are not inputs. +""" + +import logging +log = logging.getLogger(__name__) + + +from . import Win +from theming import get_theme, to_curses_attr + + +class HelpText(Win): + """ + A Window just displaying a read-only message. + Usually used to replace an Input when the tab is in + command mode. + """ + def __init__(self, text=''): + Win.__init__(self) + self.txt = text + + def refresh(self, txt=None): + log.debug('Refresh: %s', self.__class__.__name__) + if txt: + self.txt = txt + self._win.erase() + self.addstr(0, 0, self.txt[:self.width-1], to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) + self.finish_line(get_theme().COLOR_INFORMATION_BAR) + self._refresh() + + def do_command(self, key, raw=False): + return False + + def on_delete(self): + return + +class YesNoInput(Win): + """ + A Window just displaying a Yes/No input + Used to ask a confirmation + """ + def __init__(self, text='', callback=None): + Win.__init__(self) + self.key_func = { + 'y' : self.on_yes, + 'n' : self.on_no, + } + self.txt = text + self.value = None + self.callback = callback + + def on_yes(self): + self.value = True + + def on_no(self): + self.value = False + + def refresh(self, txt=None): + log.debug('Refresh: %s', self.__class__.__name__) + if txt: + self.txt = txt + self._win.erase() + self.addstr(0, 0, self.txt[:self.width-1], to_curses_attr(get_theme().COLOR_WARNING_PROMPT)) + self.finish_line(get_theme().COLOR_WARNING_PROMPT) + self._refresh() + + def do_command(self, key, raw=False): + if key.lower() in self.key_func: + self.key_func[key]() + if self.value is not None and self.callback is not None: + return self.callback() + + def on_delete(self): + return + diff --git a/poezio/windows/inputs.py b/poezio/windows/inputs.py new file mode 100644 index 00000000..80f0c900 --- /dev/null +++ b/poezio/windows/inputs.py @@ -0,0 +1,768 @@ +""" +Text inputs. +""" + +import logging +log = logging.getLogger(__name__) + +import curses +import string + +import keyboard +import common +import poopt +from . import Win +from . base_wins import format_chars +from . funcs import find_first_format_char +from config import config +from theming import to_curses_attr + + +class Input(Win): + """ + The simplest Input possible, provides just a way to edit a single line + of text. It also has a clipboard, common to all Inputs. + Doesn't have any history. + It doesn't do anything when enter is pressed either. + This should be herited for all kinds of Inputs, for example MessageInput + or the little inputs in dataforms, etc, adding specific features (completion etc) + It features two kinds of completion, but they have to be called from outside (the Tab), + passing the list of items that can be used to complete. The completion can be used + in a very flexible way. + """ + text_attributes = ['b', 'o', 'u', '1', '2', '3', '4', '5', '6', '7', 't'] + clipboard = '' # A common clipboard for all the inputs, this makes + # it easy cut and paste text between various input + def __init__(self): + self.key_func = { + "KEY_LEFT": self.key_left, + "KEY_RIGHT": self.key_right, + "KEY_END": self.key_end, + "KEY_HOME": self.key_home, + "KEY_DC": self.key_dc, + '^D': self.key_dc, + 'M-b': self.jump_word_left, + "M-[1;5D": self.jump_word_left, + "kRIT5": self.jump_word_right, + "kLFT5": self.jump_word_left, + '^W': self.delete_word, + 'M-d': self.delete_next_word, + '^K': self.delete_end_of_line, + '^U': self.delete_beginning_of_line, + '^Y': self.paste_clipboard, + '^A': self.key_home, + '^E': self.key_end, + 'M-f': self.jump_word_right, + "M-[1;5C": self.jump_word_right, + "KEY_BACKSPACE": self.key_backspace, + "M-KEY_BACKSPACE": self.delete_word, + '^?': self.key_backspace, + "M-^?": self.delete_word, + # '^J': self.add_line_break, + } + Win.__init__(self) + self.text = '' + self.pos = 0 # The position of the “cursor” in the text + # (not only in the view) + self.view_pos = 0 # The position (in the text) of the + # first character displayed on the + # screen + self.on_input = None # callback called on any key pressed + self.color = None # use this color on addstr + + def on_delete(self): + """ + Remove all references kept to a tab, so that the tab + can be garbage collected + """ + del self.key_func + + def set_color(self, color): + self.color = color + self.rewrite_text() + + def is_empty(self): + if self.text: + return False + return True + + def is_cursor_at_end(self): + """ + Whether or not the cursor is at the end of the text. + """ + assert len(self.text) >= self.pos + if len(self.text) == self.pos: + return True + return False + + def jump_word_left(self): + """ + Move the cursor one word to the left + """ + if self.pos == 0: + return True + separators = string.punctuation+' ' + while self.pos > 0 and self.text[self.pos-1] in separators: + self.key_left() + while self.pos > 0 and self.text[self.pos-1] not in separators: + self.key_left() + return True + + def jump_word_right(self): + """ + Move the cursor one word to the right + """ + if self.is_cursor_at_end(): + return True + separators = string.punctuation+' ' + while not self.is_cursor_at_end() and self.text[self.pos] in separators: + self.key_right() + while not self.is_cursor_at_end() and self.text[self.pos] not in separators: + self.key_right() + return True + + def delete_word(self): + """ + Delete the word just before the cursor + """ + separators = string.punctuation+' ' + while self.pos > 0 and self.text[self.pos-1] in separators: + self.key_backspace() + while self.pos > 0 and self.text[self.pos-1] not in separators: + self.key_backspace() + return True + + def delete_next_word(self): + """ + Delete the word just after the cursor + """ + separators = string.punctuation+' ' + while not self.is_cursor_at_end() and self.text[self.pos] in separators: + self.key_dc() + while not self.is_cursor_at_end() and self.text[self.pos] not in separators: + self.key_dc() + return True + + def delete_end_of_line(self): + """ + Cut the text from cursor to the end of line + """ + if self.is_cursor_at_end(): + return False + Input.clipboard = self.text[self.pos:] + self.text = self.text[:self.pos] + self.key_end() + return True + + def delete_beginning_of_line(self): + """ + Cut the text from cursor to the beginning of line + """ + if self.pos == 0: + return True + Input.clipboard = self.text[:self.pos] + self.text = self.text[self.pos:] + self.key_home() + return True + + def paste_clipboard(self): + """ + Insert what is in the clipboard at the cursor position + """ + if not Input.clipboard: + return True + for letter in Input.clipboard: + self.do_command(letter, False) + self.rewrite_text() + return True + + def key_dc(self): + """ + delete char just after the cursor + """ + self.reset_completion() + if self.is_cursor_at_end(): + return True # end of line, nothing to delete + self.text = self.text[:self.pos]+self.text[self.pos+1:] + self.rewrite_text() + return True + + def key_home(self): + """ + Go to the beginning of line + """ + self.reset_completion() + self.pos = 0 + self.rewrite_text() + return True + + def key_end(self, reset=False): + """ + Go to the end of line + """ + if reset: + self.reset_completion() + self.pos = len(self.text) + assert self.is_cursor_at_end() + self.rewrite_text() + return True + + def key_left(self, jump=True, reset=True): + """ + Move the cursor one char to the left + """ + if reset: + self.reset_completion() + if self.pos == 0: + return True + self.pos -= 1 + if reset: + self.rewrite_text() + return True + + def key_right(self, jump=True, reset=True): + """ + Move the cursor one char to the right + """ + if reset: + self.reset_completion() + if self.is_cursor_at_end(): + return True + self.pos += 1 + if reset: + self.rewrite_text() + return True + + def key_backspace(self, reset=True): + """ + Delete the char just before the cursor + """ + self.reset_completion() + if self.pos == 0: + return + self.key_left() + self.key_dc() + return True + + def auto_completion(self, word_list, add_after='', quotify=True): + """ + Complete the input, from a list of words + if add_after is None, we use the value defined in completion + plus a space, after the completion. If it's a string, we use it after the + completion (with no additional space) + """ + if quotify: + for i, word in enumerate(word_list[:]): + word_list[i] = '"' + word + '"' + self.normal_completion(word_list, add_after) + return True + + def new_completion(self, word_list, argument_position=-1, add_after='', quotify=True, override=False): + """ + Complete the argument at position ``argument_postion`` in the input. + If ``quotify`` is ``True``, then the completion will operate on block of words + (e.g. "toto titi") whereas if it is ``False``, it will operate on words (e.g + "toto", "titi"). + + The completions may modify other parts of the input when completing an argument, + for example removing useless double quotes around single-words, or setting the + space between each argument to only one space. + + The case where we complete the first argument is special, because we complete + the command, and we do not want to modify anything else in the input. + + This method is the one that should be used if the command being completed + has several arguments. + """ + if argument_position == 0: + self._new_completion_first(word_list) + else: + self._new_completion_args(word_list, argument_position, add_after, quotify, override) + self.rewrite_text() + return True + + def _new_completion_args(self, word_list, argument_position=-1, add_after='', quoted=True, override=False): + """ + Case for completing arguments with position ≠ 0 + """ + if quoted: + words = common.shell_split(self.text) + else: + words = self.text.split() + if argument_position >= len(words): + current = '' + else: + current = words[argument_position] + + if quoted: + split_words = words[1:] + words = [words[0]] + for word in split_words: + if ' ' in word or '\\' in word: + words.append('"' + word + '"') + else: + words.append(word) + current_l = current.lower() + if self.last_completion is not None: + self.hit_list.append(self.hit_list.pop(0)) + else: + if override: + hit_list = word_list + else: + hit_list = [] + for word in word_list: + if word.lower().startswith(current_l): + hit_list.append(word) + if not hit_list: + return + self.hit_list = hit_list + + if argument_position >= len(words): + if quoted and ' ' in self.hit_list[0]: + words.append('"'+self.hit_list[0]+'"') + else: + words.append(self.hit_list[0]) + else: + if quoted and ' ' in self.hit_list[0]: + words[argument_position] = '"'+self.hit_list[0]+'"' + else: + words[argument_position] = self.hit_list[0] + + new_pos = -1 + for i, word in enumerate(words): + if argument_position >= i: + new_pos += len(word) + 1 + + self.last_completion = self.hit_list[0] + self.text = words[0] + ' ' + ' '.join(words[1:]) + self.pos = new_pos + + def _new_completion_first(self, word_list): + """ + Special case of completing the command itself: + we don’t want to change anything to the input doing that + """ + space_pos = self.text.find(' ') + if space_pos != -1: + current, follow = self.text[:space_pos], self.text[space_pos:] + else: + current, follow = self.text, '' + + if self.last_completion: + self.hit_list.append(self.hit_list.pop(0)) + else: + hit_list = [] + for word in word_list: + if word.lower().startswith(current): + hit_list.append(word) + if not hit_list: + return + self.hit_list = hit_list + + self.last_completion = self.hit_list[0] + self.text = self.hit_list[0] + follow + self.pos = len(self.hit_list[0]) + + def get_argument_position(self, quoted=True): + """ + Get the argument number at the current position + """ + command_stop = self.text.find(' ') + if command_stop == -1 or self.pos <= command_stop: + return 0 + text = self.text[command_stop+1:] + pos = self.pos - len(self.text) + len(text) - 1 + val = common.find_argument(pos, text, quoted=quoted) + 1 + return val + + def reset_completion(self): + """ + Reset the completion list (called on ALL keys except tab) + """ + self.hit_list = [] + self.last_completion = None + + def normal_completion(self, word_list, after): + """ + Normal completion + """ + pos = self.pos + if pos < len(self.text) and after.endswith(' ') and self.text[pos] == ' ': + after = after[:-1] # remove the last space if we are already on a space + if not self.last_completion: + space_before_cursor = self.text.rfind(' ', 0, pos) + if space_before_cursor != -1: + begin = self.text[space_before_cursor+1:pos] + else: + begin = self.text[:pos] + hit_list = [] # list of matching hits + for word in word_list: + if word.lower().startswith(begin.lower()): + hit_list.append(word) + elif word.startswith('"') and word.lower()[1:].startswith(begin.lower()): + hit_list.append(word) + if len(hit_list) == 0: + return + self.hit_list = hit_list + end = len(begin) + else: + begin = self.last_completion + end = len(begin) + len(after) + self.hit_list.append(self.hit_list.pop(0)) # rotate list + + self.text = self.text[:pos-end] + self.text[pos:] + pos -= end + hit = self.hit_list[0] # take the first hit + self.text = self.text[:pos] + hit + after + self.text[pos:] + for _ in range(end): + try: + self.key_left(reset=False) + except: + pass + for _ in range(len(hit) + len(after)): + self.key_right(reset=False) + + self.rewrite_text() + self.last_completion = hit + + def do_command(self, key, reset=True, raw=False): + if key in self.key_func: + res = self.key_func[key]() + if not raw and self.on_input: + self.on_input(self.get_text()) + return res + if not raw and (not key or len(key) > 1): + return False # ignore non-handled keyboard shortcuts + if reset: + self.reset_completion() + # Insert the char at the cursor position + self.text = self.text[:self.pos]+key+self.text[self.pos:] + self.pos += len(key) + if reset: + self.rewrite_text() + if self.on_input: + self.on_input(self.get_text()) + + return True + + def add_line_break(self): + """ + Add a (real) \n to the line + """ + self.do_command('\n') + + def get_text(self): + """ + Return the text entered so far + """ + return self.text + + def addstr_colored_lite(self, text, y=None, x=None): + """ + Just like addstr_colored, with the single-char attributes + (\x0E to \x19 instead of \x19 + attr). We do not use any } + char in this version + """ + chars = format_chars[:] + chars.append('\n') + if y is not None and x is not None: + self.move(y, x) + format_char = find_first_format_char(text, chars) + while format_char != -1: + if text[format_char] == '\n': + attr_char = '|' + else: + attr_char = self.text_attributes[ + format_chars.index(text[format_char])] + self.addstr(text[:format_char]) + self.addstr(attr_char, curses.A_REVERSE) + text = text[format_char+1:] + if attr_char == 'o': + self._win.attrset(0) + elif attr_char == 'u': + self._win.attron(curses.A_UNDERLINE) + elif attr_char == 'b': + self._win.attron(curses.A_BOLD) + elif attr_char in string.digits and attr_char != '': + self._win.attron(to_curses_attr((int(attr_char), -1))) + format_char = find_first_format_char(text, chars) + self.addstr(text) + + def rewrite_text(self): + """ + Refresh the line onscreen, but first, always adjust the + view_pos. Also, each FORMAT_CHAR+attr_char count only take + one screen column (this is done in addstr_colored_lite), we + have to do some special calculations to find the correct + length of text to display, and the position of the cursor. + """ + self.adjust_view_pos() + text = self.text + self._win.erase() + if self.color: + self._win.attron(to_curses_attr(self.color)) + displayed_text = text[self.view_pos:self.view_pos+self.width-1].replace('\t', '\x18') + self._win.attrset(0) + self.addstr_colored_lite(displayed_text) + # Fill the rest of the line with the input color + if self.color: + (_, x) = self._win.getyx() + size = self.width - x + self.addnstr(' ' * size, size, to_curses_attr(self.color)) + self.addstr(0, + poopt.wcswidth(displayed_text[:self.pos-self.view_pos]), '') + if self.color: + self._win.attroff(to_curses_attr(self.color)) + curses.curs_set(1) + self._refresh() + + def adjust_view_pos(self): + """ + Adjust the position of the View, if needed (for example if the + cursor moved and would now be out of the view, we adapt the + view_pos so that we can always see our cursor) + """ + # start of the input + if self.pos == 0: + self.view_pos = 0 + return + # cursor outside of the screen (left) + if self.pos <= self.view_pos: + self.view_pos = self.pos - max(1 * self.width // 3, 1) + # cursor outside of the screen (right) + elif self.pos >= self.view_pos + self.width - 1: + self.view_pos = self.pos - max(2 * self.width // 3, 2) + + if self.view_pos < 0: + self.view_pos = 0 + + # text small enough to fit inside the window entirely: + # remove scrolling if present + if poopt.wcswidth(self.text) < self.width: + self.view_pos = 0 + + def refresh(self): + log.debug('Refresh: %s', self.__class__.__name__) + self.rewrite_text() + + def clear_text(self): + self.text = '' + self.pos = 0 + self.rewrite_text() + + def key_enter(self): + txt = self.get_text() + self.clear_text() + return txt + +class HistoryInput(Input): + """ + An input with colors and stuff, plus an history + ^R allows to search inside the history (as in a shell) + """ + history = list() + + def __init__(self): + Input.__init__(self) + self.help_message = '' + self.current_completed = '' + self.key_func['^R'] = self.toggle_search + self.search = False + if config.get('separate_history'): + self.history = list() + + def toggle_search(self): + if self.help_message: + return + self.search = not self.search + self.refresh() + + def update_completed(self): + """ + Find a match for the current text + """ + if not self.text: + return + for i in self.history: + if self.text in i: + self.current_completed = i + return + self.current_completed = '' + + def history_enter(self): + """ + Enter was pressed, set the text to the + current completion and disable history + search + """ + if self.search: + self.search = False + if self.current_completed: + self.text = self.current_completed + self.current_completed = '' + self.refresh() + return True + self.refresh() + return False + + def key_up(self): + """ + Get the previous line in the history + """ + self.reset_completion() + if self.histo_pos == -1 and self.get_text(): + if not self.history or self.history[0] != self.get_text(): + # add the message to history, we do not want to lose it + self.history.insert(0, self.get_text()) + self.histo_pos += 1 + if self.histo_pos < len(self.history) - 1: + self.histo_pos += 1 + self.text = self.history[self.histo_pos] + self.key_end() + return True + + def key_down(self): + """ + Get the next line in the history + """ + self.reset_completion() + if self.histo_pos > 0: + self.histo_pos -= 1 + self.text = self.history[self.histo_pos] + elif self.histo_pos <= 0 and self.get_text(): + if not self.history or self.history[0] != self.get_text(): + # add the message to history, we do not want to lose it + self.history.insert(0, self.get_text()) + self.text = '' + self.histo_pos = -1 + self.key_end() + return True + +class MessageInput(HistoryInput): + """ + The input featuring history and that is being used in + Conversation, Muc and Private tabs + Also letting the user enter colors or other text markups + """ + history = list() # The history is common to all MessageInput + + def __init__(self): + HistoryInput.__init__(self) + self.last_completion = None + self.histo_pos = -1 + self.key_func["KEY_UP"] = self.key_up + self.key_func["M-A"] = self.key_up + self.key_func["KEY_DOWN"] = self.key_down + self.key_func["M-B"] = self.key_down + self.key_func['^C'] = self.enter_attrib + + def enter_attrib(self): + """ + Read one more char (c), add the corresponding char from formats_char to the text string + """ + def cb(attr_char): + if attr_char in self.text_attributes: + char = format_chars[self.text_attributes.index(attr_char)] + self.do_command(char, False) + self.rewrite_text() + keyboard.continuation_keys_callback = cb + + def key_enter(self): + if self.history_enter(): + return + + txt = self.get_text() + if len(txt) != 0: + if not self.history or self.history[0] != txt: + # add the message to history, but avoid duplicates + self.history.insert(0, txt) + self.histo_pos = -1 + self.clear_text() + return txt + +class CommandInput(HistoryInput): + """ + An input with an help message in the left, with three given callbacks: + one when when successfully 'execute' the command and when we abort it. + The last callback is optional and is called on any input key + This input is used, for example, in the RosterTab when, to replace the + HelpMessage when a command is started + The on_input callback + """ + history = list() + + def __init__(self, help_message, on_abort, on_success, on_input=None): + HistoryInput.__init__(self) + self.on_abort = on_abort + self.on_success = on_success + self.on_input = on_input + self.help_message = help_message + self.key_func['^M'] = self.success + self.key_func['^G'] = self.abort + self.key_func['^C'] = self.abort + self.key_func["KEY_UP"] = self.key_up + self.key_func["M-A"] = self.key_up + self.key_func["KEY_DOWN"] = self.key_down + self.key_func["M-B"] = self.key_down + self.histo_pos = -1 + + def do_command(self, key, refresh=True, raw=False): + res = Input.do_command(self, key, refresh, raw) + if self.on_input: + self.on_input(self.get_text()) + return res + + def disable_history(self): + """ + Disable the history (up/down) keys + """ + if 'KEY_UP' in self.key_func: + del self.key_func['KEY_UP'] + if 'KEY_DOWN' in self.key_func: + del self.key_func['KEY_DOWN'] + + @property + def history_disabled(self): + return 'KEY_UP' not in self.key_func and 'KEY_DOWN' not in self.key_func + + def success(self): + """ + call the success callback, passing the text as argument + """ + self.on_input = None + if self.search: + self.history_enter() + res = self.on_success(self.get_text()) + return res + + def abort(self): + """ + Call the abort callback, passing the text as argument + """ + self.on_input = None + return self.on_abort(self.get_text()) + + def on_delete(self): + """ + SERIOUSLY BIG WTF. + + I can do + self.key_func.clear() + + but not + del self.key_func + because that would raise an AttributeError exception. WTF. + """ + self.on_abort = None + self.on_success = None + self.on_input = None + self.key_func.clear() + + def key_enter(self): + txt = self.get_text() + if len(txt) != 0: + if not self.history or self.history[0] != txt: + # add the message to history, but avoid duplicates + self.history.insert(0, txt) + self.histo_pos = -1 + diff --git a/poezio/windows/list.py b/poezio/windows/list.py new file mode 100644 index 00000000..677df6ff --- /dev/null +++ b/poezio/windows/list.py @@ -0,0 +1,236 @@ +""" +Windows relevant for the listing tabs, not much else +""" + +import logging +log = logging.getLogger(__name__) + +import curses + +from . import Win +from theming import to_curses_attr, get_theme + + +class ListWin(Win): + """ + A list (with no depth, so not for the roster) that can be + scrolled up and down, with one selected line at a time + """ + def __init__(self, columns, with_headers=True): + Win.__init__(self) + self._columns = columns # a dict {'column_name': tuple_index} + self._columns_sizes = {} # a dict {'column_name': size} + self.sorted_by = (None, None) # for example: ('name', '↑') + self.lines = [] # a list of dicts + self._selected_row = 0 + self._starting_pos = 0 # The column number from which we start the refresh + + @property + def pos(self): + if len(self.lines) > self.height: + return len(self.lines) + else: + return 0 + + def empty(self): + """ + emtpy the list and reset some important values as well + """ + self.lines = [] + self._selected_row = 0 + self._starting_pos = 0 + + def resize_columns(self, dic): + """ + Resize the width of the columns + """ + self._columns_sizes = dic + + def sort_by_column(self, col_name, asc=True): + """ + Sort the list by the given column, ascendant or descendant + """ + if not col_name: + return + elif asc: + self.lines.sort(key=lambda x: x[self._columns[col_name]]) + else: + self.lines.sort(key=lambda x: x[self._columns[col_name]], + reverse=True) + self.refresh() + curses.doupdate() + + def add_lines(self, lines): + """ + Append some lines at the end of the list + """ + if not lines: + return + self.lines.extend(lines) + + def set_lines(self, lines): + """ + Set the lines to another list + """ + if not lines: + return + self.lines = lines + + def get_selected_row(self): + """ + Return the tuple representing the selected row + """ + if self._selected_row is not None and self.lines: + return self.lines[self._selected_row] + return None + + def refresh(self): + log.debug('Refresh: %s', self.__class__.__name__) + self._win.erase() + lines = self.lines[self._starting_pos:self._starting_pos+self.height] + for y, line in enumerate(lines): + x = 0 + for col in self._columns.items(): + try: + txt = line[col[1]] or '' + except KeyError: + txt = '' + size = self._columns_sizes[col[0]] + txt += ' ' * (size-len(txt)) + if not txt: + continue + if line is self.lines[self._selected_row]: + self.addstr(y, x, txt[:size], + to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) + else: + self.addstr(y, x, txt[:size]) + x += size + self._refresh() + + def move_cursor_down(self): + """ + Move the cursor Down + """ + if not self.lines: + return + if self._selected_row < len(self.lines) - 1: + self._selected_row += 1 + while self._selected_row >= self._starting_pos + self.height: + self._starting_pos += self.height // 2 + if self._starting_pos < 0: + self._starting_pos = 0 + return True + + def move_cursor_up(self): + """ + Move the cursor Up + """ + if not self.lines: + return + if self._selected_row > 0: + self._selected_row -= 1 + while self._selected_row < self._starting_pos: + self._starting_pos -= self.height // 2 + return True + + def scroll_down(self): + if not self.lines: + return + self._selected_row += self.height + if self._selected_row > len(self.lines) - 1: + self._selected_row = len(self.lines) -1 + while self._selected_row >= self._starting_pos + self.height: + self._starting_pos += self.height // 2 + if self._starting_pos < 0: + self._starting_pos = 0 + return True + + def scroll_up(self): + if not self.lines: + return + self._selected_row -= self.height + 1 + if self._selected_row < 0: + self._selected_row = 0 + while self._selected_row < self._starting_pos: + self._starting_pos -= self.height // 2 + return True + +class ColumnHeaderWin(Win): + """ + A class displaying the column's names + """ + def __init__(self, columns): + Win.__init__(self) + self._columns = columns + self._columns_sizes = {} + self._column_sel = '' + self._column_order = '' + self._column_order_asc = False + + def resize_columns(self, dic): + self._columns_sizes = dic + + def get_columns(self): + return self._columns + + def refresh(self): + log.debug('Refresh: %s', self.__class__.__name__) + self._win.erase() + x = 0 + for col in self._columns: + txt = col + if col in self._column_order: + if self._column_order_asc: + txt += get_theme().CHAR_COLUMN_ASC + else: + txt += get_theme().CHAR_COLUMN_DESC + #⇓⇑↑↓⇧⇩▲▼ + size = self._columns_sizes[col] + txt += ' ' * (size-len(txt)) + if col in self._column_sel: + self.addstr(0, x, txt, to_curses_attr(get_theme().COLOR_COLUMN_HEADER_SEL)) + else: + self.addstr(0, x, txt, to_curses_attr(get_theme().COLOR_COLUMN_HEADER)) + x += size + self._refresh() + + def sel_column(self, dic): + self._column_sel = dic + + def get_sel_column(self): + return self._column_sel + + def set_order(self, order): + self._column_order = self._column_sel + self._column_order_asc = order + + def get_order(self): + if self._column_sel == self._column_order: + return self._column_order_asc + else: + return False + + def sel_column_left(self): + if self._column_sel in self._columns: + index = self._columns.index(self._column_sel) + if index > 1: + index = index -1 + else: + index = 0 + else: + index = 0 + self._column_sel = self._columns[index] + self.refresh() + + def sel_column_right(self): + if self._column_sel in self._columns: + index = self._columns.index(self._column_sel) + if index < len(self._columns)-2: + index = index +1 + else: + index = len(self._columns) -1 + else: + index = len(self._columns) - 1 + self._column_sel = self._columns[index] + self.refresh() + diff --git a/poezio/windows/misc.py b/poezio/windows/misc.py new file mode 100644 index 00000000..07c91bbd --- /dev/null +++ b/poezio/windows/misc.py @@ -0,0 +1,60 @@ +""" +Wins that don’t fit any category +""" + +import logging +log = logging.getLogger(__name__) + +import curses + +from . import Win +from theming import get_theme, to_curses_attr + +class VerticalSeparator(Win): + """ + Just a one-column window, with just a line in it, that is + refreshed only on resize, but never on refresh, for efficiency + """ + def __init__(self): + Win.__init__(self) + + def rewrite_line(self): + self._win.vline(0, 0, curses.ACS_VLINE, self.height, + to_curses_attr(get_theme().COLOR_VERTICAL_SEPARATOR)) + self._refresh() + + def refresh(self): + log.debug('Refresh: %s', self.__class__.__name__) + self.rewrite_line() + + +class SimpleTextWin(Win): + def __init__(self, text): + Win.__init__(self) + self._text = text + self.built_lines = [] + + def rebuild_text(self): + """ + Transform the text in lines than can then be + displayed without any calculation or anything + at refresh() time + It is basically called on each resize + """ + self.built_lines = [] + for line in self._text.split('\n'): + while len(line) >= self.width: + limit = line[:self.width].rfind(' ') + if limit <= 0: + limit = self.width + self.built_lines.append(line[:limit]) + line = line[limit:] + self.built_lines.append(line) + + def refresh(self): + log.debug('Refresh: %s', self.__class__.__name__) + self._win.erase() + for y, line in enumerate(self.built_lines): + self.addstr_colored(line, y, 0) + self._refresh() + diff --git a/poezio/windows/muc.py b/poezio/windows/muc.py new file mode 100644 index 00000000..84775787 --- /dev/null +++ b/poezio/windows/muc.py @@ -0,0 +1,143 @@ +""" +Windows specific to a MUC +""" + +import logging +log = logging.getLogger(__name__) + +import curses + +from . import Win + +import poopt +from config import config +from theming import to_curses_attr, get_theme + +def userlist_to_cache(userlist): + result = [] + for user in userlist: + result.append((user.nick, user.status, user.chatstate, user.affiliation, user.role)) + return result + +class UserList(Win): + def __init__(self): + Win.__init__(self) + self.pos = 0 + self.cache = [] + + def scroll_up(self): + self.pos += self.height-1 + return True + + def scroll_down(self): + pos = self.pos + self.pos -= self.height-1 + if self.pos < 0: + self.pos = 0 + return self.pos != pos + + def draw_plus(self, y): + self.addstr(y, self.width-2, '++', to_curses_attr(get_theme().COLOR_MORE_INDICATOR)) + + + def refresh_if_changed(self, users): + old = self.cache + new = userlist_to_cache(users[self.pos:self.pos+self.height]) + if len(old) != len(new): + self.cache = new + self.refresh(users) + return + for i in range(len(old)): + if old[i] != new[i]: + self.cache = new + self.refresh(users) + + def refresh(self, users): + log.debug('Refresh: %s', self.__class__.__name__) + if config.get('hide_user_list'): + return # do not refresh if this win is hidden. + if len(users) < self.height: + self.pos = 0 + elif self.pos >= len(users) - self.height and self.pos != 0: + self.pos = len(users) - self.height + self._win.erase() + asc_sort = (config.get('user_list_sort').lower() == 'asc') + if asc_sort: + y, x = self._win.getmaxyx() + y -= 1 + else: + y = 0 + + for user in users[self.pos:self.pos+self.height]: + self.draw_role_affiliation(y, user) + self.draw_status_chatstate(y, user) + self.addstr(y, 2, + poopt.cut_by_columns(user.nick, self.width - 2), + to_curses_attr(user.color)) + if asc_sort: + y -= 1 + else: + y += 1 + if y == self.height: + break + # draw indicators of position in the list + if self.pos > 0: + if asc_sort: + self.draw_plus(self.height-1) + else: + self.draw_plus(0) + if self.pos + self.height < len(users): + if asc_sort: + self.draw_plus(0) + else: + self.draw_plus(self.height-1) + self._refresh() + + def draw_role_affiliation(self, y, user): + theme = get_theme() + color = theme.color_role(user.role) + symbol = theme.char_affiliation(user.affiliation) + self.addstr(y, 1, symbol, to_curses_attr(color)) + + def draw_status_chatstate(self, y, user): + show_col = get_theme().color_show(user.show) + if user.chatstate == 'composing': + char = get_theme().CHAR_CHATSTATE_COMPOSING + elif user.chatstate == 'active': + char = get_theme().CHAR_CHATSTATE_ACTIVE + elif user.chatstate == 'paused': + char = get_theme().CHAR_CHATSTATE_PAUSED + else: + char = get_theme().CHAR_STATUS + self.addstr(y, 0, char, to_curses_attr(show_col)) + + def resize(self, height, width, y, x): + separator = to_curses_attr(get_theme().COLOR_VERTICAL_SEPARATOR) + self._resize(height, width, y, x) + self._win.attron(separator) + self._win.vline(0, 0, curses.ACS_VLINE, self.height) + self._win.attroff(separator) + +class Topic(Win): + def __init__(self): + Win.__init__(self) + self._message = '' + + def refresh(self, topic=None): + log.debug('Refresh: %s', self.__class__.__name__) + self._win.erase() + if topic: + msg = topic[:self.width-1] + else: + msg = self._message[:self.width-1] + self.addstr(0, 0, msg, to_curses_attr(get_theme().COLOR_TOPIC_BAR)) + (y, x) = self._win.getyx() + remaining_size = self.width - x + if remaining_size: + self.addnstr(' '*remaining_size, remaining_size, + to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) + self._refresh() + + def set_message(self, message): + self._message = message + diff --git a/poezio/windows/roster_win.py b/poezio/windows/roster_win.py new file mode 100644 index 00000000..a2e2badd --- /dev/null +++ b/poezio/windows/roster_win.py @@ -0,0 +1,387 @@ +""" +Windows used with the roster (window displaying the contacts, and the +one showing detailed info on the current selection) +""" +import logging +log = logging.getLogger(__name__) + +from datetime import datetime + +from . import Win + +import common +from config import config +from contact import Contact, Resource +from roster import RosterGroup +from theming import get_theme, to_curses_attr + + +class RosterWin(Win): + + def __init__(self): + Win.__init__(self) + self.pos = 0 # cursor position in the contact list + self.start_pos = 1 # position of the start of the display + self.selected_row = None + self.roster_cache = [] + + @property + def roster_len(self): + return len(self.roster_cache) + + def move_cursor_down(self, number=1): + """ + Return True if we scrolled, False otherwise + """ + pos = self.pos + if self.pos < self.roster_len-number: + self.pos += number + else: + self.pos = self.roster_len - 1 + if self.pos >= self.start_pos-1 + self.height-1: + if number == 1: + self.scroll_down(8) + else: + self.scroll_down(self.pos-self.start_pos - self.height // 2) + self.update_pos() + return pos != self.pos + + def move_cursor_up(self, number=1): + """ + Return True if we scrolled, False otherwise + """ + pos = self.pos + if self.pos-number >= 0: + self.pos -= number + else: + self.pos = 0 + if self.pos <= self.start_pos: + if number == 1: + self.scroll_up(8) + else: + self.scroll_up(self.start_pos-self.pos + self.height // 2) + self.update_pos() + return pos != self.pos + + def update_pos(self): + if len(self.roster_cache) > self.pos and self.pos >= 0: + self.selected_row = self.roster_cache[self.pos] + elif self.roster_cache: + self.selected_row = self.roster_cache[-1] + + def scroll_down(self, number=8): + pos = self.start_pos + if self.start_pos + number <= self.roster_len-1: + self.start_pos += number + else: + self.start_pos = self.roster_len-1 + return self.start_pos != pos + + def scroll_up(self, number=8): + pos = self.start_pos + if self.start_pos - number > 0: + self.start_pos -= number + else: + self.start_pos = 1 + return self.start_pos != pos + + def build_roster_cache(self, roster): + """ + Regenerates the roster cache if needed + """ + if not roster.needs_rebuild: + return + log.debug('The roster has changed, rebuilding the cache…') + # This is a search + if roster.contact_filter: + self.roster_cache = [] + sort = config.get('roster_sort', 'jid:show') or 'jid:show' + for contact in roster.get_contacts_sorted_filtered(sort): + self.roster_cache.append(contact) + else: + show_offline = config.get('roster_show_offline') or roster.contact_filter + sort = config.get('roster_sort') or 'jid:show' + group_sort = config.get('roster_group_sort') or 'name' + self.roster_cache = [] + # build the cache + for group in roster.get_groups(group_sort): + contacts_filtered = group.get_contacts(roster.contact_filter) + if (not show_offline and group.get_nb_connected_contacts() == 0) or not contacts_filtered: + continue # Ignore empty groups + self.roster_cache.append(group) + if group.folded: + continue # ignore folded groups + for contact in group.get_contacts(roster.contact_filter, sort): + if not show_offline and len(contact) == 0: + continue # ignore offline contacts + self.roster_cache.append(contact) + if not contact.folded(group.name): + for resource in contact.get_resources(): + self.roster_cache.append(resource) + roster.last_built = datetime.now() + if self.selected_row in self.roster_cache: + if self.pos < self.roster_len and self.roster_cache[self.pos] != self.selected_row: + self.pos = self.roster_cache.index(self.selected_row) + + def refresh(self, roster): + """ + We display a number of lines from the roster cache + (and rebuild it if needed) + """ + log.debug('Refresh: %s', self.__class__.__name__) + self.build_roster_cache(roster) + # make sure we are within bounds + self.move_cursor_up((self.roster_len + self.pos) if self.pos >= self.roster_len else 0) + if not self.roster_cache: + self.selected_row = None + self._win.erase() + self._win.move(0, 0) + self.draw_roster_information(roster) + y = 1 + group = "none" + # scroll down if needed + if self.start_pos+self.height <= self.pos+2: + self.scroll_down(self.pos - self.start_pos - self.height + (self.height//2)) + # draw the roster from the cache + roster_view = self.roster_cache[self.start_pos-1:self.start_pos+self.height] + + options = { + 'show_roster_sub': config.get('show_roster_subscriptions'), + 'show_s2s_errors': config.get('show_s2s_errors'), + 'show_roster_jids': config.get('show_roster_jids') + } + + for item in roster_view: + draw_selected = False + if y -2 + self.start_pos == self.pos: + draw_selected = True + self.selected_row = item + + if isinstance(item, RosterGroup): + self.draw_group(y, item, draw_selected) + group = item.name + elif isinstance(item, Contact): + self.draw_contact_line(y, item, draw_selected, group, **options) + elif isinstance(item, Resource): + self.draw_resource_line(y, item, draw_selected) + + y += 1 + + if self.start_pos > 1: + self.draw_plus(1) + if self.start_pos + self.height-2 < self.roster_len: + self.draw_plus(self.height-1) + self._refresh() + + + def draw_plus(self, y): + """ + Draw the indicator that shows that + the list is longer than what is displayed + """ + self.addstr(y, self.width-5, '++++', to_curses_attr(get_theme().COLOR_MORE_INDICATOR)) + + def draw_roster_information(self, roster): + """ + The header at the top + """ + self.addstr('Roster: %s/%s contacts' % ( + roster.get_nb_connected_contacts(), + len(roster)), + to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) + self.finish_line(get_theme().COLOR_INFORMATION_BAR) + + def draw_group(self, y, group, colored): + """ + Draw a groupname on a line + """ + if colored: + self._win.attron(to_curses_attr(get_theme().COLOR_SELECTED_ROW)) + if group.folded: + self.addstr(y, 0, '[+] ') + else: + self.addstr(y, 0, '[-] ') + contacts = " (%s/%s)" % (group.get_nb_connected_contacts(), len(group)) + self.addstr(y, 4, self.truncate_name(group.name, len(contacts)+4) + contacts) + if colored: + self._win.attroff(to_curses_attr(get_theme().COLOR_SELECTED_ROW)) + self.finish_line() + + def truncate_name(self, name, added): + if len(name) + added <= self.width: + return name + return name[:self.width - added - 1] + '…' + + def draw_contact_line(self, y, contact, colored, group, show_roster_sub=False, + show_s2s_errors=True, show_roster_jids=False): + """ + Draw on a line all informations about one contact. + This is basically the highest priority resource's informations + Use 'color' to draw the jid/display_name to show what is + the currently selected contact in the list + """ + + theme = get_theme() + resource = contact.get_highest_priority_resource() + if not resource: + # There's no online resource + presence = 'unavailable' + nb = '' + else: + presence = resource.presence + nb = ' (%s)' % len(contact) + color = theme.color_show(presence) + added = 2 + len(theme.CHAR_STATUS) + len(nb) + + self.addstr(y, 0, ' ') + self.addstr(theme.CHAR_STATUS, to_curses_attr(color)) + + self.addstr(' ') + if resource: + self.addstr('[+] ' if contact.folded(group) else '[-] ') + added += 4 + if contact.ask: + added += len(get_theme().CHAR_ROSTER_ASKED) + if show_s2s_errors and contact.error: + added += len(get_theme().CHAR_ROSTER_ERROR) + if contact.tune: + added += len(get_theme().CHAR_ROSTER_TUNE) + if contact.mood: + added += len(get_theme().CHAR_ROSTER_MOOD) + if contact.activity: + added += len(get_theme().CHAR_ROSTER_ACTIVITY) + if contact.gaming: + added += len(get_theme().CHAR_ROSTER_GAMING) + if show_roster_sub in ('all', 'incomplete', 'to', 'from', 'both', 'none'): + added += len(theme.char_subscription(contact.subscription, keep=show_roster_sub)) + + if not show_roster_jids and contact.name: + display_name = '%s' % contact.name + elif contact.name and contact.name != contact.bare_jid: + display_name = '%s (%s)' % (contact.name, contact.bare_jid) + else: + display_name = '%s' % (contact.bare_jid,) + + display_name = self.truncate_name(display_name, added) + nb + + if colored: + self.addstr(display_name, to_curses_attr(get_theme().COLOR_SELECTED_ROW)) + else: + self.addstr(display_name) + + if show_roster_sub in ('all', 'incomplete', 'to', 'from', 'both', 'none'): + self.addstr(theme.char_subscription(contact.subscription, keep=show_roster_sub), to_curses_attr(theme.COLOR_ROSTER_SUBSCRIPTION)) + if contact.ask: + self.addstr(get_theme().CHAR_ROSTER_ASKED, to_curses_attr(get_theme().COLOR_IMPORTANT_TEXT)) + if show_s2s_errors and contact.error: + self.addstr(get_theme().CHAR_ROSTER_ERROR, to_curses_attr(get_theme().COLOR_ROSTER_ERROR)) + if contact.tune: + self.addstr(get_theme().CHAR_ROSTER_TUNE, to_curses_attr(get_theme().COLOR_ROSTER_TUNE)) + if contact.activity: + self.addstr(get_theme().CHAR_ROSTER_ACTIVITY, to_curses_attr(get_theme().COLOR_ROSTER_ACTIVITY)) + if contact.mood: + self.addstr(get_theme().CHAR_ROSTER_MOOD, to_curses_attr(get_theme().COLOR_ROSTER_MOOD)) + if contact.gaming: + self.addstr(get_theme().CHAR_ROSTER_GAMING, to_curses_attr(get_theme().COLOR_ROSTER_GAMING)) + self.finish_line() + + def draw_resource_line(self, y, resource, colored): + """ + Draw a specific resource line + """ + color = get_theme().color_show(resource.presence) + self.addstr(y, 4, get_theme().CHAR_STATUS, to_curses_attr(color)) + if colored: + self.addstr(y, 6, self.truncate_name(str(resource.jid), 6), to_curses_attr(get_theme().COLOR_SELECTED_ROW)) + else: + self.addstr(y, 6, self.truncate_name(str(resource.jid), 6)) + self.finish_line() + + def get_selected_row(self): + if self.pos >= len(self.roster_cache): + return self.selected_row + if len(self.roster_cache) > 0: + self.selected_row = self.roster_cache[self.pos] + return self.roster_cache[self.pos] + return None + +class ContactInfoWin(Win): + def __init__(self): + Win.__init__(self) + + def draw_contact_info(self, contact): + """ + draw the contact information + """ + resource = contact.get_highest_priority_resource() + if contact: + jid = contact.bare_jid + elif resource: + jid = resource.jid + else: + jid = 'example@example.com' # should never happen + if resource: + presence = resource.presence + else: + presence = 'unavailable' + i = 0 + self.addstr(0, 0, '%s (%s)'%(jid, presence,), to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) + self.finish_line(get_theme().COLOR_INFORMATION_BAR) + i += 1 + self.addstr(i, 0, 'Subscription: %s' % (contact.subscription,)) + self.finish_line() + i += 1 + if contact.ask: + if contact.ask == 'asked': + self.addstr(i, 0, 'Ask: %s' % (contact.ask,), to_curses_attr(get_theme().COLOR_IMPORTANT_TEXT)) + else: + self.addstr(i, 0, 'Ask: %s' % (contact.ask,)) + self.finish_line() + i += 1 + if resource: + self.addstr(i, 0, 'Status: %s' % (resource.status)) + self.finish_line() + i += 1 + + if contact.error: + self.addstr(i, 0, 'Error: %s' % contact.error, to_curses_attr(get_theme().COLOR_ROSTER_ERROR)) + self.finish_line() + i += 1 + + if contact.tune: + self.addstr(i, 0, 'Tune: %s' % common.format_tune_string(contact.tune), to_curses_attr(get_theme().COLOR_NORMAL_TEXT)) + self.finish_line() + i += 1 + + if contact.mood: + self.addstr(i, 0, 'Mood: %s' % contact.mood, to_curses_attr(get_theme().COLOR_NORMAL_TEXT)) + self.finish_line() + i += 1 + + if contact.activity: + self.addstr(i, 0, 'Activity: %s' % contact.activity, to_curses_attr(get_theme().COLOR_NORMAL_TEXT)) + self.finish_line() + i += 1 + + if contact.gaming: + self.addstr(i, 0, 'Game: %s' % common.format_gaming_string(contact.gaming), to_curses_attr(get_theme().COLOR_NORMAL_TEXT)) + self.finish_line() + i += 1 + + def draw_group_info(self, group): + """ + draw the group information + """ + self.addstr(0, 0, group.name, to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) + self.finish_line(get_theme().COLOR_INFORMATION_BAR) + + def refresh(self, selected_row): + log.debug('Refresh: %s', self.__class__.__name__) + self._win.erase() + if isinstance(selected_row, RosterGroup): + self.draw_group_info(selected_row) + elif isinstance(selected_row, Contact): + self.draw_contact_info(selected_row) + # elif isinstance(selected_row, Resource): + # self.draw_contact_info(None, selected_row) + self._refresh() diff --git a/poezio/windows/text_win.py b/poezio/windows/text_win.py new file mode 100644 index 00000000..fd1fe546 --- /dev/null +++ b/poezio/windows/text_win.py @@ -0,0 +1,597 @@ +""" +TextWin, the window showing the text messages and info messages in poezio. +Can be locked, scrolled, has a separator, etc… +""" + +import logging +log = logging.getLogger(__name__) + +import curses +from math import ceil, log10 + +from . import Win +from . base_wins import FORMAT_CHAR, Line +from . funcs import truncate_nick, parse_attrs + +import poopt +from config import config +from theming import to_curses_attr, get_theme, dump_tuple + + +class BaseTextWin(Win): + def __init__(self, lines_nb_limit=None): + if lines_nb_limit is None: + lines_nb_limit = config.get('max_lines_in_memory') + Win.__init__(self) + self.lines_nb_limit = lines_nb_limit + self.pos = 0 + self.built_lines = [] # Each new message is built and kept here. + # on resize, we rebuild all the messages + + self.lock = False + self.lock_buffer = [] + self.separator_after = None + + def toggle_lock(self): + if self.lock: + self.release_lock() + else: + self.acquire_lock() + return self.lock + + def acquire_lock(self): + self.lock = True + + def release_lock(self): + for line in self.lock_buffer: + self.built_lines.append(line) + self.lock = False + + def scroll_up(self, dist=14): + pos = self.pos + self.pos += dist + if self.pos + self.height > len(self.built_lines): + self.pos = len(self.built_lines) - self.height + if self.pos < 0: + self.pos = 0 + return self.pos != pos + + def scroll_down(self, dist=14): + pos = self.pos + self.pos -= dist + if self.pos <= 0: + self.pos = 0 + return self.pos != pos + + def build_new_message(self, message, history=None, clean=True, highlight=False, timestamp=False, nick_size=10): + """ + Take one message, build it and add it to the list + Return the number of lines that are built for the given + message. + """ + lines = self.build_message(message, timestamp=timestamp, nick_size=nick_size) + if self.lock: + self.lock_buffer.extend(lines) + else: + self.built_lines.extend(lines) + if not lines or not lines[0]: + return 0 + if clean: + while len(self.built_lines) > self.lines_nb_limit: + self.built_lines.pop(0) + return len(lines) + + def build_message(self, message, timestamp=False, nick_size=10): + """ + Build a list of lines from a message, without adding it + to a list + """ + pass + + def refresh(self): + pass + + def write_text(self, y, x, txt): + """ + write the text of a line. + """ + self.addstr_colored(txt, y, x) + + def write_time(self, time): + """ + Write the date on the yth line of the window + """ + if time: + color = get_theme().COLOR_TIME_STRING + curses_color = to_curses_attr(color) + self._win.attron(curses_color) + self.addstr(time) + self._win.attroff(curses_color) + self.addstr(' ') + return poopt.wcswidth(time) + 1 + return 0 + + def resize(self, height, width, y, x, room=None): + if hasattr(self, 'width'): + old_width = self.width + else: + old_width = None + self._resize(height, width, y, x) + if room and self.width != old_width: + self.rebuild_everything(room) + + # reposition the scrolling after resize + # (see #2450) + buf_size = len(self.built_lines) + if buf_size - self.pos < self.height: + self.pos = buf_size - self.height + if self.pos < 0: + self.pos = 0 + + def rebuild_everything(self, room): + self.built_lines = [] + with_timestamps = config.get('show_timestamps') + nick_size = config.get('max_nick_length') + for message in room.messages: + self.build_new_message(message, clean=False, timestamp=with_timestamps, nick_size=nick_size) + if self.separator_after is message: + self.build_new_message(None) + while len(self.built_lines) > self.lines_nb_limit: + self.built_lines.pop(0) + + def __del__(self): + log.debug('** TextWin: deleting %s built lines', (len(self.built_lines))) + del self.built_lines + +class TextWin(BaseTextWin): + def __init__(self, lines_nb_limit=None): + BaseTextWin.__init__(self, lines_nb_limit) + + # the Lines of the highlights in that buffer + self.highlights = [] + # the current HL position in that list NaN means that we’re not on + # an hl. -1 is a valid position (it's before the first hl of the + # list. i.e the separator, in the case where there’s no hl before + # it.) + self.hl_pos = float('nan') + + # Keep track of the number of hl after the separator. + # This is useful to make “go to next highlight“ work after a “move to separator”. + self.nb_of_highlights_after_separator = 0 + + self.separator_after = None + + def next_highlight(self): + """ + Go to the next highlight in the buffer. + (depending on which highlight was selected before) + if the buffer is already positionned on the last, of if there are no + highlights, scroll to the end of the buffer. + """ + log.debug('Going to the next highlight…') + if (not self.highlights or self.hl_pos != self.hl_pos or + self.hl_pos >= len(self.highlights) - 1): + self.hl_pos = float('nan') + self.pos = 0 + return + hl_size = len(self.highlights) - 1 + if self.hl_pos < hl_size: + self.hl_pos += 1 + else: + self.hl_pos = hl_size + log.debug("self.hl_pos = %s", self.hl_pos) + hl = self.highlights[self.hl_pos] + pos = None + while not pos: + try: + pos = self.built_lines.index(hl) + except ValueError: + self.highlights = self.highlights[self.hl_pos+1:] + if not self.highlights: + self.hl_pos = float('nan') + self.pos = 0 + return + self.hl_pos = 0 + hl = self.highlights[0] + self.pos = len(self.built_lines) - pos - self.height + if self.pos < 0 or self.pos >= len(self.built_lines): + self.pos = 0 + + def previous_highlight(self): + """ + Go to the previous highlight in the buffer. + (depending on which highlight was selected before) + if the buffer is already positionned on the first, or if there are no + highlights, scroll to the end of the buffer. + """ + log.debug('Going to the previous highlight…') + if not self.highlights or self.hl_pos <= 0: + self.hl_pos = float('nan') + self.pos = 0 + return + if self.hl_pos != self.hl_pos: + self.hl_pos = len(self.highlights) - 1 + else: + self.hl_pos -= 1 + log.debug("self.hl_pos = %s", self.hl_pos) + hl = self.highlights[self.hl_pos] + pos = None + while not pos: + try: + pos = self.built_lines.index(hl) + except ValueError: + self.highlights = self.highlights[self.hl_pos+1:] + if not self.highlights: + self.hl_pos = float('nan') + self.pos = 0 + return + self.hl_pos = 0 + hl = self.highlights[0] + self.pos = len(self.built_lines) - pos - self.height + if self.pos < 0 or self.pos >= len(self.built_lines): + self.pos = 0 + + def scroll_to_separator(self): + """ + Scroll until separator is centered. If no separator is + present, scroll at the top of the window + """ + if None in self.built_lines: + self.pos = len(self.built_lines) - self.built_lines.index(None) - self.height + 1 + if self.pos < 0: + self.pos = 0 + else: + self.pos = len(self.built_lines) - self.height + 1 + # Chose a proper position (not too high) + self.scroll_up(0) + # Make “next highlight” work afterwards. This makes it easy to + # review all the highlights since the separator was placed, in + # the correct order. + self.hl_pos = len(self.highlights) - self.nb_of_highlights_after_separator - 1 + log.debug("self.hl_pos = %s", self.hl_pos) + + def remove_line_separator(self): + """ + Remove the line separator + """ + log.debug('remove_line_separator') + if None in self.built_lines: + self.built_lines.remove(None) + self.separator_after = None + + def add_line_separator(self, room=None): + """ + add a line separator at the end of messages list + room is a textbuffer that is needed to get the previous message + (in case of resize) + """ + if None not in self.built_lines: + self.built_lines.append(None) + self.nb_of_highlights_after_separator = 0 + log.debug("Reseting number of highlights after separator") + if room and room.messages: + self.separator_after = room.messages[-1] + + def build_new_message(self, message, history=None, clean=True, highlight=False, timestamp=False, nick_size=10): + """ + Take one message, build it and add it to the list + Return the number of lines that are built for the given + message. + """ + lines = self.build_message(message, timestamp=timestamp, nick_size=nick_size) + if self.lock: + self.lock_buffer.extend(lines) + else: + self.built_lines.extend(lines) + if not lines or not lines[0]: + return 0 + if highlight: + self.highlights.append(lines[0]) + self.nb_of_highlights_after_separator += 1 + log.debug("Number of highlights after separator is now %s", + self.nb_of_highlights_after_separator) + if clean: + while len(self.built_lines) > self.lines_nb_limit: + self.built_lines.pop(0) + return len(lines) + + def build_message(self, message, timestamp=False, nick_size=10): + """ + Build a list of lines from a message, without adding it + to a list + """ + if message is None: # line separator + return [None] + txt = message.txt + if not txt: + return [] + if len(message.str_time) > 8: + default_color = (FORMAT_CHAR + dump_tuple(get_theme().COLOR_LOG_MSG) + + '}') + else: + default_color = None + ret = [] + nick = truncate_nick(message.nickname, nick_size) + offset = 0 + if message.ack: + if message.ack > 0: + offset += poopt.wcswidth(get_theme().CHAR_ACK_RECEIVED) + 1 + else: + offset += poopt.wcswidth(get_theme().CHAR_NACK) + 1 + if nick: + offset += poopt.wcswidth(nick) + 2 # + nick + '> ' length + if message.revisions > 0: + offset += ceil(log10(message.revisions + 1)) + if message.me: + offset += 1 # '* ' before and ' ' after + if timestamp: + if message.str_time: + offset += 1 + len(message.str_time) + if get_theme().CHAR_TIME_LEFT and message.str_time: + offset += 1 + if get_theme().CHAR_TIME_RIGHT and message.str_time: + offset += 1 + lines = poopt.cut_text(txt, self.width-offset-1) + prepend = default_color if default_color else '' + attrs = [] + for line in lines: + saved = Line(msg=message, start_pos=line[0], end_pos=line[1], prepend=prepend) + attrs = parse_attrs(message.txt[line[0]:line[1]], attrs) + if attrs: + prepend = FORMAT_CHAR + FORMAT_CHAR.join(attrs) + else: + if default_color: + prepend = default_color + else: + prepend = '' + ret.append(saved) + return ret + + def refresh(self): + log.debug('Refresh: %s', self.__class__.__name__) + if self.height <= 0: + return + if self.pos == 0: + lines = self.built_lines[-self.height:] + else: + lines = self.built_lines[-self.height-self.pos:-self.pos] + with_timestamps = config.get("show_timestamps") + nick_size = config.get("max_nick_length") + self._win.move(0, 0) + self._win.erase() + offset = 0 + for y, line in enumerate(lines): + if line: + msg = line.msg + if line.start_pos == 0: + offset = self.write_pre_msg(msg, with_timestamps, nick_size) + elif y == 0: + offset = self.compute_offset(msg, with_timestamps, nick_size) + self.write_text(y, offset, line.prepend + + line.msg.txt[line.start_pos:line.end_pos]) + else: + self.write_line_separator(y) + if y != self.height-1: + self.addstr('\n') + self._win.attrset(0) + self._refresh() + + def compute_offset(self, msg, with_timestamps, nick_size): + offset = 0 + if with_timestamps and msg.str_time: + offset += poopt.wcswidth(msg.str_time) + 1 + + if not msg.nickname: # not a message, nothing to do afterwards + return offset + + nick = truncate_nick(msg.nickname, nick_size) + offset += poopt.wcswidth(nick) + if msg.ack: + if msg.ack > 0: + offset += poopt.wcswidth(get_theme().CHAR_ACK_RECEIVED) + 1 + else: + offset += poopt.wcswidth(get_theme().CHAR_NACK) + 1 + if msg.me: + offset += 3 + else: + offset += 2 + if msg.revisions: + offset += ceil(log10(msg.revisions + 1)) + offset += self.write_revisions(msg) + return offset + + + def write_pre_msg(self, msg, with_timestamps, nick_size): + offset = 0 + if with_timestamps: + offset += self.write_time(msg.str_time) + + if not msg.nickname: # not a message, nothing to do afterwards + return offset + + nick = truncate_nick(msg.nickname, nick_size) + offset += poopt.wcswidth(nick) + if msg.nick_color: + color = msg.nick_color + elif msg.user: + color = msg.user.color + else: + color = None + if msg.ack: + if msg.ack > 0: + offset += self.write_ack() + else: + offset += self.write_nack() + if msg.me: + self._win.attron(to_curses_attr(get_theme().COLOR_ME_MESSAGE)) + self.addstr('* ') + self.write_nickname(nick, color, msg.highlight) + offset += self.write_revisions(msg) + self.addstr(' ') + offset += 3 + else: + self.write_nickname(nick, color, msg.highlight) + offset += self.write_revisions(msg) + self.addstr('> ') + offset += 2 + return offset + + def write_revisions(self, msg): + if msg.revisions: + self._win.attron(to_curses_attr(get_theme().COLOR_REVISIONS_MESSAGE)) + self.addstr('%d' % msg.revisions) + self._win.attrset(0) + return ceil(log10(msg.revisions + 1)) + return 0 + + def write_line_separator(self, y): + char = get_theme().CHAR_NEW_TEXT_SEPARATOR + self.addnstr(y, 0, + char * (self.width // len(char) - 1), + self.width, + to_curses_attr(get_theme().COLOR_NEW_TEXT_SEPARATOR)) + + def write_ack(self): + color = get_theme().COLOR_CHAR_ACK + self._win.attron(to_curses_attr(color)) + self.addstr(get_theme().CHAR_ACK_RECEIVED) + self._win.attroff(to_curses_attr(color)) + self.addstr(' ') + return poopt.wcswidth(get_theme().CHAR_ACK_RECEIVED) + 1 + + def write_nack(self): + color = get_theme().COLOR_CHAR_NACK + self._win.attron(to_curses_attr(color)) + self.addstr(get_theme().CHAR_NACK) + self._win.attroff(to_curses_attr(color)) + self.addstr(' ') + return poopt.wcswidth(get_theme().CHAR_NACK) + 1 + + def write_nickname(self, nickname, color, highlight=False): + """ + Write the nickname, using the user's color + and return the number of written characters + """ + if not nickname: + return + if highlight: + hl_color = get_theme().COLOR_HIGHLIGHT_NICK + if hl_color == "reverse": + self._win.attron(curses.A_REVERSE) + else: + color = hl_color + if color: + self._win.attron(to_curses_attr(color)) + self.addstr(nickname) + if color: + self._win.attroff(to_curses_attr(color)) + if highlight and hl_color == "reverse": + self._win.attroff(curses.A_REVERSE) + + def modify_message(self, old_id, message): + """ + Find a message, and replace it with a new one + (instead of rebuilding everything in order to correct a message) + """ + with_timestamps = config.get('show_timestamps') + nick_size = config.get('max_nick_length') + for i in range(len(self.built_lines)-1, -1, -1): + if self.built_lines[i] and self.built_lines[i].msg.identifier == old_id: + index = i + while index >= 0 and self.built_lines[index] and self.built_lines[index].msg.identifier == old_id: + self.built_lines.pop(index) + index -= 1 + index += 1 + lines = self.build_message(message, timestamp=with_timestamps, nick_size=nick_size) + for line in lines: + self.built_lines.insert(index, line) + index += 1 + break + + def __del__(self): + log.debug('** TextWin: deleting %s built lines', (len(self.built_lines))) + del self.built_lines + +class XMLTextWin(BaseTextWin): + def __init__(self): + BaseTextWin.__init__(self) + + def refresh(self): + log.debug('Refresh: %s', self.__class__.__name__) + theme = get_theme() + if self.height <= 0: + return + if self.pos == 0: + lines = self.built_lines[-self.height:] + else: + lines = self.built_lines[-self.height-self.pos:-self.pos] + self._win.move(0, 0) + self._win.erase() + for y, line in enumerate(lines): + if line: + msg = line.msg + if line.start_pos == 0: + if msg.nickname == theme.CHAR_XML_OUT: + color = theme.COLOR_XML_OUT + elif msg.nickname == theme.CHAR_XML_IN: + color = theme.COLOR_XML_IN + self.write_time(msg.str_time) + self.write_prefix(msg.nickname, color) + self.addstr(' ') + if y != self.height-1: + self.addstr('\n') + self._win.attrset(0) + for y, line in enumerate(lines): + offset = 0 + # Offset for the timestamp (if any) plus a space after it + offset += len(line.msg.str_time) + # space + offset += 1 + + # Offset for the prefix + offset += poopt.wcswidth(truncate_nick(line.msg.nickname)) + # space + offset += 1 + + self.write_text(y, offset, line.prepend + + line.msg.txt[line.start_pos:line.end_pos]) + if y != self.height-1: + self.addstr('\n') + self._win.attrset(0) + self._refresh() + + def build_message(self, message, timestamp=False, nick_size=10): + txt = message.txt + ret = [] + default_color = None + nick = truncate_nick(message.nickname, nick_size) + offset = 0 + if nick: + offset += poopt.wcswidth(nick) + 1 # + nick + ' ' length + if message.str_time: + offset += 1 + len(message.str_time) + if get_theme().CHAR_TIME_LEFT and message.str_time: + offset += 1 + if get_theme().CHAR_TIME_RIGHT and message.str_time: + offset += 1 + lines = poopt.cut_text(txt, self.width-offset-1) + prepend = default_color if default_color else '' + attrs = [] + for line in lines: + saved = Line(msg=message, start_pos=line[0], end_pos=line[1], prepend=prepend) + attrs = parse_attrs(message.txt[line[0]:line[1]], attrs) + if attrs: + prepend = FORMAT_CHAR + FORMAT_CHAR.join(attrs) + else: + if default_color: + prepend = default_color + else: + prepend = '' + ret.append(saved) + return ret + + def write_prefix(self, nickname, color): + self._win.attron(to_curses_attr(color)) + self.addstr(truncate_nick(nickname)) + self._win.attroff(to_curses_attr(color)) + diff --git a/poezio/xhtml.py b/poezio/xhtml.py new file mode 100644 index 00000000..b84ce943 --- /dev/null +++ b/poezio/xhtml.py @@ -0,0 +1,543 @@ +# Copyright 2010-2011 Florent Le Coz <louiz@louiz.org> +# +# This file is part of Poezio. +# +# Poezio is free software: you can redistribute it and/or modify +# it under the terms of the zlib license. See the COPYING file. + +""" +Various methods to convert +shell colors to poezio colors, +xhtml code to shell colors, +poezio colors to xhtml code +""" + +import base64 +import curses +import hashlib +import re +from os import path +from slixmpp.xmlstream import ET +from urllib.parse import unquote + +from io import BytesIO +from xml import sax +from xml.sax import saxutils + +digits = '0123456789' # never trust the modules + +XHTML_NS = 'http://www.w3.org/1999/xhtml' + +# HTML named colors +colors = { + 'aliceblue': 231, + 'antiquewhite': 231, + 'aqua': 51, + 'aquamarine': 122, + 'azure': 231, + 'beige': 231, + 'bisque': 230, + 'black': 232, + 'blanchedalmond': 230, + 'blue': 21, + 'blueviolet': 135, + 'brown': 124, + 'burlywood': 223, + 'cadetblue': 109, + 'chartreuse': 118, + 'chocolate': 172, + 'coral': 209, + 'cornflowerblue': 111, + 'cornsilk': 231, + 'crimson': 197, + 'cyan': 51, + 'darkblue': 19, + 'darkcyan': 37, + 'darkgoldenrod': 178, + 'darkgray': 247, + 'darkgreen': 28, + 'darkgrey': 247, + 'darkkhaki': 186, + 'darkmagenta': 127, + 'darkolivegreen': 65, + 'darkorange': 214, + 'darkorchid': 134, + 'darkred': 124, + 'darksalmon': 216, + 'darkseagreen': 151, + 'darkslateblue': 61, + 'darkslategray': 59, + 'darkslategrey': 59, + 'darkturquoise': 44, + 'darkviolet': 128, + 'deeppink': 199, + 'deepskyblue': 45, + 'dimgray': 241, + 'dimgrey': 241, + 'dodgerblue': 39, + 'firebrick': 160, + 'floralwhite': 231, + 'forestgreen': 34, + 'fuchsia': 201, + 'gainsboro': 252, + 'ghostwhite': 231, + 'gold': 226, + 'goldenrod': 214, + 'gray': 244, + 'green': 34, + 'greenyellow': 191, + 'grey': 244, + 'honeydew': 231, + 'hotpink': 212, + 'indianred': 174, + 'indigo': 55, + 'ivory': 231, + 'khaki': 229, + 'lavender': 231, + 'lavenderblush': 231, + 'lawngreen': 118, + 'lemonchiffon': 230, + 'lightblue': 195, + 'lightcoral': 217, + 'lightcyan': 231, + 'lightgoldenrodyellow': 230, + 'lightgray': 251, + 'lightgreen': 157, + 'lightgrey': 251, + 'lightpink': 224, + 'lightsalmon': 216, + 'lightseagreen': 43, + 'lightskyblue': 153, + 'lightslategray': 109, + 'lightslategrey': 109, + 'lightsteelblue': 189, + 'lightyellow': 231, + 'lime': 46, + 'limegreen': 77, + 'linen': 231, + 'magenta': 201, + 'maroon': 124, + 'mediumaquamarine': 115, + 'mediumblue': 20, + 'mediumorchid': 170, + 'mediumpurple': 141, + 'mediumseagreen': 78, + 'mediumslateblue': 105, + 'mediumspringgreen': 49, + 'mediumturquoise': 80, + 'mediumvioletred': 163, + 'midnightblue': 18, + 'mintcream': 231, + 'mistyrose': 231, + 'moccasin': 230, + 'navajowhite': 230, + 'navy': 19, + 'oldlace': 231, + 'olive': 142, + 'olivedrab': 106, + 'orange': 214, + 'orangered': 202, + 'orchid': 213, + 'palegoldenrod': 229, + 'palegreen': 157, + 'paleturquoise': 195, + 'palevioletred': 211, + 'papayawhip': 231, + 'peachpuff': 230, + 'peru': 179, + 'pink': 224, + 'plum': 219, + 'powderblue': 195, + 'purple': 127, + 'red': 196, + 'rosybrown': 181, + 'royalblue': 69, + 'saddlebrown': 130, + 'salmon': 216, + 'sandybrown': 216, + 'seagreen': 72, + 'seashell': 231, + 'sienna': 131, + 'silver': 250, + 'skyblue': 153, + 'slateblue': 104, + 'slategray': 109, + 'slategrey': 109, + 'snow': 231, + 'springgreen': 48, + 'steelblue': 74, + 'tan': 187, + 'teal': 37, + 'thistle': 225, + 'tomato': 209, + 'turquoise': 86, + 'violet': 219, + 'wheat': 230, + 'white': 255, + 'whitesmoke': 255, + 'yellow': 226, + 'yellowgreen': 149 +} + +whitespace_re = re.compile(r'\s+') + +xhtml_attr_re = re.compile(r'\x19-?\d[^}]*}|\x19[buaio]') +xhtml_data_re = re.compile(r'data:image/([a-z]+);base64,(.+)') +poezio_color_double = re.compile(r'(?:\x19\d+}|\x19\d)+(\x19\d|\x19\d+})') +poezio_format_trim = re.compile(r'(\x19\d+}|\x19\d|\x19[buaio]|\x19o)+\x19o') + +xhtml_simple_attr_re = re.compile(r'\x19\d') + +def get_body_from_message_stanza(message, use_xhtml=False, + tmp_dir=None, extract_images=False): + """ + Returns a string with xhtml markups converted to + poezio colors if there's an xhtml_im element, or + the body (without any color) otherwise + """ + if use_xhtml: + xhtml = message['html'].xml + xhtml_body = xhtml.find('{http://www.w3.org/1999/xhtml}body') + if xhtml_body: + content = xhtml_to_poezio_colors(xhtml_body, tmp_dir=tmp_dir, + extract_images=extract_images) + content = content if content else message['body'] + return content or " " + return message['body'] + +def ncurses_color_to_html(color): + """ + Takes an int between 0 and 256 and returns + a string of the form #XXXXXX representing an + html color. + """ + if color <= 15: + try: + (r, g, b) = curses.color_content(color) + except: # fallback in faulty terminals (e.g. xterm) + (r, g, b) = curses.color_content(color%8) + r = r / 1000 * 6 - 0.01 + g = g / 1000 * 6 - 0.01 + b = b / 1000 * 6 - 0.01 + elif color <= 231: + color = color - 16 + r = color % 6 + color = color / 6 + g = color % 6 + color = color / 6 + b = color % 6 + else: + color -= 232 + r = g = b = color / 24 * 6 + return '#%02X%02X%02X' % (r*256/6, g*256/6, b*256/6) + +def parse_css(css): + def get_color(value): + if value[0] == '#': + value = value[1:] + length = len(value) + if length != 3 and length != 6: + return -1 + value = int(value, 16) + if length == 6: + r = int(value >> 16) + g = int((value >> 8) & 0xff) + b = int(value & 0xff) + if r == g == b: + return 232 + int(r/10.6251) + div = 42.51 + else: + r = int(value >> 8) + g = int((value >> 4) & 0xf) + b = int(value & 0xf) + if r == g == b: + return 232 + int(1.54*r) + div = 2.51 + return 6*6*int(r/div) + 6*int(g/div) + int(b/div) + 16 + if value in colors: + return colors[value] + return -1 + shell = '' + rules = css.split(';') + for rule in rules: + if ':' not in rule: + continue + key, value = rule.split(':', 1) + key = key.strip() + value = value.strip() + if key == 'background-color': + pass#shell += '\x191' + elif key == 'color': + color = get_color(value) + if color != -1: + shell += '\x19%d}' % color + elif key == 'font-style': + shell += '\x19i' + elif key == 'font-weight': + shell += '\x19b' + elif key == 'margin-left': + shell += ' ' + elif key == 'text-align': + pass + elif key == 'text-decoration': + if value == 'underline': + shell += '\x19u' + elif value == 'blink': + shell += '\x19a' + return shell + +def trim(string): + return re.sub(whitespace_re, ' ', string) + +class XHTMLHandler(sax.ContentHandler): + def __init__(self, force_ns=False, tmp_dir=None, extract_images=False): + self.builder = [] + self.formatting = [] + self.attrs = [] + self.list_state = [] + self.is_pre = False + self.a_start = 0 + # do not care about xhtml-in namespace + self.force_ns = force_ns + + self.tmp_dir = tmp_dir + self.extract_images = extract_images + + @property + def result(self): + sanitized = re.sub(poezio_color_double, r'\1', ''.join(self.builder).strip()) + return re.sub(poezio_format_trim, '\x19o', sanitized) + + def append_formatting(self, formatting): + self.formatting.append(formatting) + self.builder.append(formatting) + + def pop_formatting(self): + self.formatting.pop() + self.builder.append('\x19o' + ''.join(self.formatting)) + + def characters(self, characters): + self.builder.append(characters if self.is_pre else trim(characters)) + + def startElementNS(self, name, _, attrs): + if name[0] != XHTML_NS and not self.force_ns: + return + + builder = self.builder + attrs = {name: value for ((ns, name), value) in attrs.items() if ns is None} + self.attrs.append(attrs) + + if 'style' in attrs: + style = parse_css(attrs['style']) + self.append_formatting(style) + + name = name[1] + if name == 'a': + self.append_formatting('\x19u') + self.a_start = len(self.builder) + elif name == 'blockquote': + builder.append('“') + elif name == 'br': + builder.append('\n') + elif name == 'cite': + self.append_formatting('\x19u') + elif name == 'em': + self.append_formatting('\x19i') + elif name == 'img': + if re.match(xhtml_data_re, attrs['src']) and self.extract_images: + type_, data = [i for i in re.split(xhtml_data_re, attrs['src']) if i] + bin_data = base64.b64decode(unquote(data)) + filename = hashlib.sha1(bin_data).hexdigest() + '.' + type_ + filepath = path.join(self.tmp_dir, filename) + if not path.exists(filepath): + try: + with open(filepath, 'wb') as fd: + fd.write(bin_data) + builder.append('file://%s' % filepath) + except Exception as e: + builder.append('[Error while saving image: %s]' % e) + else: + builder.append('file://%s' % filepath) + else: + builder.append(trim(attrs['src'])) + if 'alt' in attrs: + builder.append(' (%s)' % trim(attrs['alt'])) + elif name == 'ul': + self.list_state.append('ul') + elif name == 'ol': + self.list_state.append(1) + elif name == 'li': + try: + state = self.list_state[-1] + except IndexError: + state = 'ul' + if state == 'ul': + builder.append('\n• ') + else: + builder.append('\n%d) ' % state) + state += 1 + self.list_state[-1] = state + elif name == 'p': + builder.append('\n') + elif name == 'pre': + builder.append('\n') + self.is_pre = True + elif name == 'strong': + self.append_formatting('\x19b') + + def endElementNS(self, name, _): + if name[0] != XHTML_NS and not self.force_ns: + return + + builder = self.builder + attrs = self.attrs.pop() + name = name[1] + + if name == 'a': + self.pop_formatting() + # do not display the link twice + text_elements = filter(lambda x: not x.startswith('\x19'), + self.builder[self.a_start:]) + link_text = ''.join(text_elements).strip() + if 'href' in attrs and attrs['href'] != link_text: + builder.append(' (%s)' % trim(attrs['href'])) + elif name == 'blockquote': + builder.append('”') + elif name in ('cite', 'em', 'strong'): + self.pop_formatting() + elif name in ('ol', 'p', 'ul'): + builder.append('\n') + elif name == 'pre': + builder.append('\n') + self.is_pre = False + + if 'style' in attrs: + self.pop_formatting() + + if 'title' in attrs: + builder.append(' [' + attrs['title'] + ']') + +def xhtml_to_poezio_colors(xml, force=False, tmp_dir=None, extract_images=None): + if isinstance(xml, str): + xml = xml.encode('utf8') + elif not isinstance(xml, bytes): + xml = ET.tostring(xml) + + handler = XHTMLHandler(force_ns=force, tmp_dir=tmp_dir, + extract_images=extract_images) + parser = sax.make_parser() + parser.setFeature(sax.handler.feature_namespaces, True) + parser.setContentHandler(handler) + parser.parse(BytesIO(xml)) + return handler.result + +def clean_text(s): + """ + Remove all xhtml-im attributes (\x19etc) from the string with the + complete color format, i.e \x19xxx} + """ + s = re.sub(xhtml_attr_re, "", s) + return s + +def clean_text_simple(string): + """ + Remove all \x19 from the string formatted with simple colors: + \x198 + """ + pos = string.find('\x19') + while pos != -1: + string = string[:pos] + string[pos+2:] + pos = string.find('\x19') + return string + +def convert_simple_to_full_colors(text): + """ + takes a \x19n formatted string and returns + a \x19n} formatted one. + """ + # TODO, have a single list of this. This is some sort of + # dusplicate from windows.format_chars + mapping = str.maketrans({'\x0E': '\x19b', '\x0F': '\x19o', '\x10': '\x19u', + '\x11': '\x191', '\x12': '\x192', '\x13': '\x193', + '\x14': '\x194', '\x15': '\x195', '\x16': '\x196', + '\x17': '\x197', '\x18': '\x198', '\x19': '\x199'}) + text = text.translate(mapping) + def add_curly_bracket(match): + return match.group(0) + '}' + return re.sub(xhtml_simple_attr_re, add_curly_bracket, text) + +number_to_color_names = { + 1: 'red', + 2: 'green', + 3: 'yellow', + 4: 'blue', + 5: 'violet', + 6: 'turquoise', + 7: 'white' +} + +def format_inline_css(_dict): + return ''.join(('%s: %s;' % (key, value) for key, value in _dict.items())) + +def poezio_colors_to_html(string): + """ + Convert poezio colors to html + (e.g. \x191}: <span style='color: red'>) + """ + # Maintain a list of the current css attributes used + # And check if a tag is open (by design, we only open + # spans tag, and they cannot be nested. + current_attrs = {} + tag_open = False + next_attr_char = string.find('\x19') + build = ["<body xmlns='http://www.w3.org/1999/xhtml'><p>"] + + def check_property(key, value): + nonlocal tag_open + if current_attrs.get(key, None) == value: + return + current_attrs[key] = value + if tag_open: + tag_open = False + build.append('</span>') + + while next_attr_char != -1: + attr_char = string[next_attr_char+1].lower() + + if next_attr_char != 0 and string[:next_attr_char]: + if current_attrs and not tag_open: + build.append('<span style="%s">' % format_inline_css(current_attrs)) + tag_open = True + build.append(saxutils.escape(string[:next_attr_char])) + + if attr_char == 'o': + if tag_open: + build.append('</span>') + tag_open = False + current_attrs = {} + elif attr_char == 'b': + check_property('font-weight', 'bold') + elif attr_char == 'u': + check_property('text-decoration', 'underline') + + if attr_char in digits: + number_str = string[next_attr_char+1:string.find('}', next_attr_char)] + number = int(number_str) + if number in number_to_color_names: + check_property('color', number_to_color_names.get(number, 'black')) + else: + check_property('color', ncurses_color_to_html(number)) + string = string[next_attr_char+len(number_str)+2:] + else: + string = string[next_attr_char+2:] + next_attr_char = string.find('\x19') + + if current_attrs and not tag_open and string: + build.append('<span style="%s">' % format_inline_css(current_attrs)) + tag_open = True + build.append(saxutils.escape(string)) + if tag_open: + build.append('</span>') + build.append("</p></body>") + text = ''.join(build) + return text.replace('\n', '<br />') |