diff options
Diffstat (limited to 'poezio')
77 files changed, 8385 insertions, 5291 deletions
diff --git a/poezio/args.py b/poezio/args.py index d0005d82..3907fc88 100644 --- a/poezio/args.py +++ b/poezio/args.py @@ -1,10 +1,16 @@ """ Module related to the argument parsing - -There is a fallback to the deprecated optparse if argparse is not found """ +import pkg_resources +import stat +import sys +from argparse import ArgumentParser, SUPPRESS, Namespace from pathlib import Path -from argparse import ArgumentParser, SUPPRESS +from shutil import copy2 +from typing import Tuple + +from poezio.version import __version__ +from poezio import xdg def parse_args(CONFIG_PATH: Path): @@ -33,11 +39,48 @@ def parse_args(CONFIG_PATH: Path): help="The config file you want to use", metavar="CONFIG_FILE") parser.add_argument( - "-v", - "--version", - dest="version", + '-v', + '--version', + action='version', + version='Poezio v%s' % __version__, + ) + parser.add_argument( + "--custom-version", + dest="custom_version", help=SUPPRESS, metavar="VERSION", - default="0.13-dev") - options = parser.parse_args() - return options + default=__version__ + ) + return parser.parse_args() + + +def run_cmdline_args() -> Tuple[Namespace, bool]: + "Parse the command line arguments" + options = parse_args(xdg.CONFIG_HOME) + firstrun = False + + # Copy a default file if none exists + if not options.filename.is_file(): + try: + options.filename.parent.mkdir(parents=True, exist_ok=True) + except OSError as e: + sys.stderr.write( + 'Poezio was unable to create the config directory: %s\n' % e) + sys.exit(1) + default = Path(__file__).parent / '..' / 'data' / 'default_config.cfg' + other = Path( + pkg_resources.resource_filename('poezio', 'default_config.cfg')) + if default.is_file(): + copy2(str(default), str(options.filename)) + elif other.is_file(): + copy2(str(other), str(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 options.filename.exists(): + options.filename.chmod(options.filename.stat().st_mode + | stat.S_IWUSR) + firstrun = True + + return (options, firstrun) diff --git a/poezio/asyncio.py b/poezio/asyncio_fix.py index d333ffa6..d333ffa6 100644 --- a/poezio/asyncio.py +++ b/poezio/asyncio_fix.py diff --git a/poezio/bookmarks.py b/poezio/bookmarks.py index 0406de94..64d7a437 100644 --- a/poezio/bookmarks.py +++ b/poezio/bookmarks.py @@ -30,11 +30,20 @@ Adding a remote bookmark: import functools import logging -from typing import Optional, List, Union - -from slixmpp import JID +from typing import ( + Callable, + List, + Optional, + Union, +) + +from slixmpp import ( + InvalidJID, + JID, +) +from slixmpp.exceptions import IqError, IqTimeout from slixmpp.plugins.xep_0048 import Bookmarks, Conference, URL -from poezio.common import safeJID +from poezio.connection import Connection from poezio.config import config log = logging.getLogger(__name__) @@ -42,20 +51,43 @@ log = logging.getLogger(__name__) class Bookmark: def __init__(self, - jid: JID, + jid: Union[JID, str], name: Optional[str] = None, autojoin=False, nick: Optional[str] = None, password: Optional[str] = None, method='local') -> None: - self.jid = jid - self.name = name or jid + try: + if isinstance(jid, JID): + self._jid = jid + else: + self._jid = JID(jid) + except InvalidJID: + log.debug('Invalid JID %r provided for bookmark', jid) + raise + self.name = name or str(self.jid) self.autojoin = autojoin self.nick = nick self.password = password self._method = method @property + def jid(self) -> JID: + """Jid getter""" + return self._jid + + @jid.setter + def jid(self, jid: JID) -> None: + try: + if isinstance(jid, JID): + self._jid = jid + else: + self._jid = JID(jid) + except InvalidJID: + log.debug('Invalid JID %r provided for bookmark', jid) + raise + + @property def method(self) -> str: return self._method @@ -86,7 +118,7 @@ class Bookmark: def local(self) -> str: """Generate a str for local storage""" - local = self.jid + local = str(self.jid) if self.nick: local += '/%s' % self.nick local += ':' @@ -130,8 +162,8 @@ class Bookmark: class BookmarkList: def __init__(self): - self.bookmarks = [] # type: List[Bookmark] - preferred = config.get('use_bookmarks_method').lower() + self.bookmarks: List[Bookmark] = [] + preferred = config.getstr('use_bookmarks_method').lower() if preferred not in ('pep', 'privatexml'): preferred = 'privatexml' self.preferred = preferred @@ -149,7 +181,7 @@ class BookmarkList: return self.bookmarks[key] return None - def __in__(self, key) -> bool: + def __contains__(self, key) -> bool: if isinstance(key, (str, JID)): for bookmark in self.bookmarks: if bookmark.jid == key: @@ -191,17 +223,17 @@ class BookmarkList: self.preferred = value config.set_and_save('use_bookmarks_method', value) - def save_remote(self, xmpp, callback): + async def save_remote(self, xmpp: Connection): """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( + return await xmpp.plugin['xep_0048'].set_bookmarks( stanza_storage(self.bookmarks), method=method, - callback=callback) + ) def save_local(self): """Save the local bookmarks.""" @@ -209,86 +241,65 @@ class BookmarkList: if bookmark.method == 'local') config.set_and_save('rooms', local) - def save(self, xmpp, core=None, callback=None): + async def save(self, xmpp: Connection, core=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): + if config.getbool('use_remote_bookmarks'): + try: + result = await self.save_remote(xmpp) + if core is not None: + core.information('Bookmarks saved', 'Info') + return result + except (IqError, IqTimeout): + if core is not None: + core.information( + 'Could not save remote bookmarks.', + 'Error' + ) + raise + + async def get_pep(self, xmpp: Connection): """Add the remotely stored bookmarks via pep to the list.""" + iq = await xmpp.plugin['xep_0048'].get_bookmarks(method='xep_0223') + for conf in iq['pubsub']['items']['item']['bookmarks'][ + 'conferences']: + if isinstance(conf, URL): + continue + bookm = Bookmark.parse(conf) + self.append(bookm) + return iq - 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): + async def get_privatexml(self, xmpp: Connection): """ 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) + iq = await xmpp.plugin['xep_0048'].get_bookmarks(method='xep_0049') + for conf in iq['private']['bookmarks']['conferences']: + bookm = Bookmark.parse(conf) + self.append(bookm) + return iq - xmpp.plugin['xep_0048'].get_bookmarks(method='xep_0049', callback=_cb) - - def get_remote(self, xmpp, information, callback): + async def get_remote(self, xmpp: Connection, information: Callable): """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): + if xmpp.anon or not any(self.available_storage.values()): 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) + return await self.get_pep(xmpp) else: - self.get_privatexml(xmpp, callback=callback) + return await self.get_privatexml(xmpp) def get_local(self): """Add the locally stored bookmarks to the list.""" - rooms = config.get('rooms') + rooms = config.getlist('rooms') if not rooms: return - rooms = rooms.split(':') for room in rooms: - jid = safeJID(room) + try: + jid = JID(room) + except InvalidJID: + continue if jid.bare == '': continue if jid.resource != '': @@ -307,7 +318,7 @@ class BookmarkList: self.append(b) -def stanza_storage(bookmarks: BookmarkList) -> Bookmarks: +def stanza_storage(bookmarks: Union[BookmarkList, List[Bookmark]]) -> Bookmarks: """Generate a <storage/> stanza with the conference elements.""" storage = Bookmarks() for b in (b for b in bookmarks if b.method == 'remote'): diff --git a/poezio/colors.py b/poezio/colors.py index 6bbbb12e..62566c77 100644 --- a/poezio/colors.py +++ b/poezio/colors.py @@ -1,7 +1,8 @@ -from typing import Tuple, Dict, List +from typing import Tuple, Dict, List, Union import curses import hashlib -import math + +from . import hsluv Palette = Dict[float, int] @@ -13,6 +14,9 @@ K_B = 1 - K_R - K_G def ncurses_color_to_rgb(color: int) -> Tuple[float, float, float]: if color <= 15: + r: Union[int, float] + g: Union[int, float] + b: Union[int, float] try: (r, g, b) = curses.color_content(color) except: # fallback in faulty terminals (e.g. xterm) @@ -33,23 +37,18 @@ def ncurses_color_to_rgb(color: int) -> Tuple[float, float, float]: return r / 5, g / 5, b / 5 -def rgb_to_ycbcr(r: float, g: float, b: float) -> Tuple[float, float, float]: - y = K_R * r + K_G * g + K_B * b - cr = (r - y) / (1 - K_R) / 2 - cb = (b - y) / (1 - K_B) / 2 - return y, cb, cr - - def generate_ccg_palette(curses_palette: List[int], reference_y: float) -> Palette: - cbcr_palette = {} # type: Dict[float, Tuple[float, int]] + cbcr_palette: Dict[float, Tuple[float, int]] = {} for curses_color in curses_palette: r, g, b = ncurses_color_to_rgb(curses_color) # drop grayscale if r == g == b: continue - y, cb, cr = rgb_to_ycbcr(r, g, b) - key = round(cbcr_to_angle(cb, cr), 2) + h, _, y = hsluv.rgb_to_hsluv((r, g, b)) + # this is to keep the code compatible with earlier versions of XEP-0392 + y = y / 100 + key = round(h) try: existing_y, *_ = cbcr_palette[key] except KeyError: @@ -68,35 +67,15 @@ def text_to_angle(text: str) -> float: hf = hashlib.sha1() hf.update(text.encode("utf-8")) hue = int.from_bytes(hf.digest()[:2], "little") - return hue / 65535 * math.pi * 2 - - -def angle_to_cbcr_edge(angle: float) -> Tuple[float, float]: - cr = math.sin(angle) - cb = math.cos(angle) - if abs(cr) > abs(cb): - factor = 0.5 / abs(cr) - else: - factor = 0.5 / abs(cb) - return cb * factor, cr * factor - - -def cbcr_to_angle(cb: float, cr: float) -> float: - magn = math.sqrt(cb**2 + cr**2) - if magn > 0: - cr /= magn - cb /= magn - return math.atan2(cr, cb) % (2 * math.pi) + return hue / 65535 * 360 def ccg_palette_lookup(palette: Palette, angle: float) -> int: # try quick lookup first try: - color = palette[round(angle, 2)] + return palette[round(angle)] except KeyError: pass - else: - return color best_metric = float("inf") best = None @@ -106,6 +85,9 @@ def ccg_palette_lookup(palette: Palette, angle: float) -> int: best_metric = metric best = color + if best is None: + raise ValueError("No color in palette") + return best diff --git a/poezio/common.py b/poezio/common.py index 3a865054..6b7d2bfe 100644 --- a/poezio/common.py +++ b/poezio/common.py @@ -3,12 +3,16 @@ # 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. +# it under the terms of the GPL-3.0+ license. See the COPYING file. """ Various useful functions. """ -from datetime import datetime, timedelta +from datetime import ( + datetime, + timedelta, + timezone, +) from pathlib import Path from typing import Dict, List, Optional, Tuple, Union @@ -16,10 +20,14 @@ import os import subprocess import time import string +import logging +import itertools -from slixmpp import JID, InvalidJID, Message +from slixmpp import Message from poezio.poezio_shlex import shlex +log = logging.getLogger(__name__) + def _get_output_of_command(command: str) -> Optional[List[str]]: """ @@ -36,7 +44,7 @@ def _get_output_of_command(command: str) -> Optional[List[str]]: return None -def _is_in_path(command: str, return_abs_path=False) -> Union[bool, str]: +def _is_in_path(command: str, return_abs_path: bool = False) -> Union[bool, str]: """ Check if *command* is in the $PATH or not. @@ -103,10 +111,12 @@ def get_os_info() -> str: 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 + if process.stdout is not None: + out = process.stdout.readline().decode('utf-8').strip() + # some distros put n/a in places, so remove those + out = out.replace('n/a', '').replace('N/A', '') + return out + return '' # lsb_release executable not available, so parse files for distro_name in DISTRO_INFO: @@ -240,7 +250,7 @@ def find_delayed_tag(message: Message) -> Tuple[bool, Optional[datetime]]: find_delay = message.xml.find delay_tag = find_delay('{urn:xmpp:delay}delay') - date = None # type: Optional[datetime] + date: Optional[datetime] = None if delay_tag is not None: delayed = True date = _datetime_tuple(delay_tag.attrib['stamp']) @@ -279,7 +289,7 @@ def shell_split(st: str) -> List[str]: return ret -def find_argument(pos: int, text: str, quoted=True) -> int: +def find_argument(pos: int, text: str, quoted: bool = True) -> int: """ Split an input into a list of arguments, return the number of the argument selected by pos. @@ -334,7 +344,7 @@ def _find_argument_unquoted(pos: int, text: str) -> int: return argnum + 1 -def parse_str_to_secs(duration='') -> int: +def parse_str_to_secs(duration: str = '') -> int: """ Parse a string of with a number of d, h, m, s. @@ -362,7 +372,7 @@ def parse_str_to_secs(duration='') -> int: return result -def parse_secs_to_str(duration=0) -> str: +def parse_secs_to_str(duration: int = 0) -> str: """ Do the reverse operation of :py:func:`parse_str_to_secs`. @@ -394,7 +404,7 @@ def parse_secs_to_str(duration=0) -> str: def format_tune_string(infos: Dict[str, str]) -> str: """ - Contruct a string from a dict created from an "User tune" event. + Construct a string from a dict created from an "User tune" event. :param dict infos: Tune information :return: The formatted string @@ -449,14 +459,103 @@ def format_gaming_string(infos: Dict[str, str]) -> str: return name -def safeJID(*args, **kwargs) -> JID: +def unique_prefix_of(a: str, b: str) -> str: """ - Construct a :py:class:`slixmpp.JID` object from a string. + Return the unique prefix of `a` with `b`. + + Corner cases: - Used to avoid tracebacks during is stringprep fails - (fall back to a JID with an empty string). + * If `a` and `b` share no prefix, the first letter of `a` is returned. + * If `a` and `b` are equal, `a` is returned. + * If `a` is a prefix of `b`, `a` is returned. + * If `b` is a prefix of `a`, `b` plus the first letter of `a` after the + common prefix is returned. """ - try: - return JID(*args, **kwargs) - except InvalidJID: - return JID('') + for i, (ca, cb) in enumerate(itertools.zip_longest(a, b)): + if ca != cb: + return a[:i+1] + # both are equal, return a + return a + + +def to_utc(time_: datetime) -> datetime: + """Convert a datetime-aware time zone into raw UTC""" + if time_.tzinfo is not None: # Convert to UTC + time_ = time_.astimezone(tz=timezone.utc) + else: # Assume local tz, convert to UTC + tzone = datetime.now().astimezone().tzinfo + time_ = time_.replace(tzinfo=tzone).astimezone(tz=timezone.utc) + # Return an offset-naive datetime + return time_.replace(tzinfo=None) + + +# 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', +} + + +def get_error_message(stanza: Message, deprecated: bool = False) -> str: + """ + Takes a stanza of the form <message type='error'><error/></message> + and return a well formed string containing error information + """ + sender = stanza['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 diff --git a/poezio/config.py b/poezio/config.py index d5a81c0e..4eb43cad 100644 --- a/poezio/config.py +++ b/poezio/config.py @@ -10,35 +10,37 @@ TODO: get http://bugs.python.org/issue1410680 fixed, one day, in order to remove our ugly custom I/O methods. """ +import logging import logging.config import os -import stat import sys -import pkg_resources from configparser import RawConfigParser, NoOptionError, NoSectionError from pathlib import Path -from shutil import copy2 -from typing import Callable, Dict, List, Optional, Union, Tuple +from typing import Dict, List, Optional, Union, Tuple, cast, Any -from poezio.args import parse_args from poezio import xdg +from slixmpp import JID, InvalidJID + +log = logging.getLogger(__name__) # type: logging.Logger ConfigValue = Union[str, int, float, bool] -DEFSECTION = "Poezio" +ConfigDict = Dict[str, Dict[str, ConfigValue]] + +USE_DEFAULT_SECTION = '__DEFAULT SECTION PLACEHOLDER__' -DEFAULT_CONFIG = { +DEFAULT_CONFIG: ConfigDict = { 'Poezio': { 'ack_message_receipts': True, 'add_space_after_completion': True, 'after_completion': ',', 'alternative_nickname': '', 'auto_reconnect': True, + 'autocolor_tab_names': False, 'autorejoin_delay': '5', 'autorejoin': False, 'beep_on': 'highlight private invite disconnect', - 'bookmark_on_join': False, 'ca_cert_path': '', 'certificate': '', 'certfile': '', @@ -50,7 +52,6 @@ DEFAULT_CONFIG = { 'custom_port': '', 'default_nick': '', 'default_muc_service': '', - 'deterministic_nick_colors': True, 'device_id': '', 'nick_color_aliases': True, 'display_activity_notifications': False, @@ -74,7 +75,6 @@ DEFAULT_CONFIG = { '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, @@ -90,9 +90,10 @@ DEFAULT_CONFIG = { 'keyfile': '', 'lang': 'en', 'lazy_resize': True, - 'load_log': 10, 'log_dir': '', 'log_errors': True, + 'mam_sync': True, + 'mam_sync_limit': 2000, 'max_lines_in_memory': 2048, 'max_messages_in_memory': 2048, 'max_nick_length': 25, @@ -134,9 +135,11 @@ DEFAULT_CONFIG = { 'show_useless_separator': True, 'status': '', 'status_message': '', + 'synchronise_open_rooms': True, 'theme': 'default', 'themes_dir': '', 'tmp_image_dir': '', + 'unique_prefix_tab_names': False, 'use_bookmarks_method': '', 'use_log': True, 'use_remote_bookmarks': True, @@ -158,21 +161,33 @@ DEFAULT_CONFIG = { } -class Config(RawConfigParser): +class PoezioConfigParser(RawConfigParser): + def optionxform(self, value) -> str: + return str(value) + + +class Config: """ load/save the config to a file """ - def __init__(self, file_name: Path, default=None) -> None: - RawConfigParser.__init__(self, None) + configparser: PoezioConfigParser + file_name: Path + default: ConfigDict + default_section: str = 'Poezio' + + def __init__(self, file_name: Path, default: Optional[ConfigDict] = None) -> None: + self.configparser = PoezioConfigParser() # make the options case sensitive - self.optionxform = lambda param: str(param) self.file_name = file_name self.read_file() - self.default = default + self.default = default or {} + + def optionxform(self, value): + return str(value) def read_file(self): - RawConfigParser.read(self, str(self.file_name), encoding='utf-8') + self.configparser.read(str(self.file_name), encoding='utf-8') # Check config integrity and fix it if it’s wrong # only when the object is the main config if self.__class__ is Config: @@ -183,38 +198,62 @@ class Config(RawConfigParser): def get(self, option: str, default: Optional[ConfigValue] = None, - section=DEFSECTION) -> ConfigValue: + section: str = USE_DEFAULT_SECTION) -> Any: """ 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 section == USE_DEFAULT_SECTION: + section = self.default_section if default is None: - if self.default: - default = self.default.get(section, {}).get(option) - else: - default = '' + default = self.default.get(section, {}).get(option, '') + res: Optional[ConfigValue] try: if isinstance(default, bool): - res = self.getboolean(option, section) + res = self.configparser.getboolean(section, option) elif isinstance(default, int): - res = self.getint(option, section) + res = self.configparser.getint(section, option) elif isinstance(default, float): - res = self.getfloat(option, section) + res = self.configparser.getfloat(section, option) else: - res = self.getstr(option, section) + res = self.configparser.get(section, option) except (NoOptionError, NoSectionError, ValueError, AttributeError): - return default if default is not None else '' + return default if res is None: return default return res + def _get_default(self, option, section): + if self.default: + return self.default.get(section, {}).get(option) + else: + return '' + + def sections(self, *args, **kwargs) -> List[str]: + return self.configparser.sections(*args, **kwargs) + + def options(self, *args, **kwargs): + return self.configparser.options(*args, **kwargs) + + def has_option(self, *args, **kwargs) -> bool: + return self.configparser.has_option(*args, **kwargs) + + def has_section(self, *args, **kwargs) -> bool: + return self.configparser.has_section(*args, **kwargs) + + def add_section(self, *args, **kwargs): + return self.configparser.add_section(*args, **kwargs) + + def remove_section(self, *args, **kwargs): + return self.configparser.remove_section(*args, **kwargs) + def get_by_tabname(self, option, - tabname, + tabname: JID, fallback=True, fallback_server=True, default=''): @@ -225,11 +264,11 @@ class Config(RawConfigParser): 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, '') + default = self.default.get(self.default_section, {}).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) + return self.get(option, default, tabname.full) if fallback_server: return self.get_by_servname(tabname, option, default, fallback) if fallback: @@ -241,7 +280,10 @@ class Config(RawConfigParser): """ Try to get the value of an option for a server """ - server = safeJID(jid).server + try: + server = JID(jid).server + except InvalidJID: + server = '' if server: server = '@' + server if server in self.sections() and option in self.options(server): @@ -250,11 +292,13 @@ class Config(RawConfigParser): return self.get(option, default) return default - def __get(self, option, section=DEFSECTION, **kwargs): + def __get(self, option, section=USE_DEFAULT_SECTION, **kwargs): """ facility for RawConfigParser.get """ - return RawConfigParser.get(self, section, option, **kwargs) + if section == USE_DEFAULT_SECTION: + section = self.default_section + return self.configparser.get(section, option, **kwargs) def _get(self, section, conv, option, **kwargs): """ @@ -262,29 +306,54 @@ class Config(RawConfigParser): """ return conv(self.__get(option, section, **kwargs)) - def getstr(self, option, section=DEFSECTION): + def getstr(self, option, section=USE_DEFAULT_SECTION) -> str: """ get a value and returns it as a string """ - return self.__get(option, section) + if section == USE_DEFAULT_SECTION: + section = self.default_section + try: + return self.configparser.get(section, option) + except (NoOptionError, NoSectionError, ValueError, AttributeError): + return cast(str, self._get_default(option, section)) - def getint(self, option, section=DEFSECTION): + def getint(self, option, section=USE_DEFAULT_SECTION) -> int: """ get a value and returns it as an int """ - return RawConfigParser.getint(self, section, option) + if section == USE_DEFAULT_SECTION: + section = self.default_section + try: + return self.configparser.getint(section, option) + except (NoOptionError, NoSectionError, ValueError, AttributeError): + return cast(int, self._get_default(option, section)) - def getfloat(self, option, section=DEFSECTION): + def getfloat(self, option, section=USE_DEFAULT_SECTION) -> float: """ get a value and returns it as a float """ - return RawConfigParser.getfloat(self, section, option) + if section == USE_DEFAULT_SECTION: + section = self.default_section + try: + return self.configparser.getfloat(section, option) + except (NoOptionError, NoSectionError, ValueError, AttributeError): + return cast(float, self._get_default(option, section)) - def getboolean(self, option, section=DEFSECTION): + def getbool(self, option, section=USE_DEFAULT_SECTION) -> bool: """ get a value and returns it as a boolean """ - return RawConfigParser.getboolean(self, section, option) + if section == USE_DEFAULT_SECTION: + section = self.default_section + try: + return self.configparser.getboolean(section, option) + except (NoOptionError, NoSectionError, ValueError, AttributeError): + return cast(bool, self._get_default(option, section)) + + def getlist(self, option, section=USE_DEFAULT_SECTION) -> List[str]: + if section == USE_DEFAULT_SECTION: + section = self.default_section + return self.getstr(option, section).split(':') def write_in_file(self, section: str, option: str, value: ConfigValue) -> bool: @@ -306,7 +375,7 @@ class Config(RawConfigParser): begin, end = sections[section] pos = find_line(result_lines, begin, end, option) - if pos is -1: + if pos == -1: result_lines.insert(end, '%s = %s' % (option, value)) else: result_lines[pos] = '%s = %s' % (option, value) @@ -332,7 +401,7 @@ class Config(RawConfigParser): begin, end = sections[section] pos = find_line(result_lines, begin, end, option) - if pos is -1: + if pos == -1: log.error( 'Tried to remove a non-existing option %s' ' from section %s', option, section) @@ -380,8 +449,7 @@ class Config(RawConfigParser): if file_ok(self.file_name): try: with self.file_name.open('r', encoding='utf-8') as df: - lines_before = [line.strip() - for line in df] # type: List[str] + lines_before: List[str] = [line.strip() for line in df] except OSError: log.error( 'Unable to read the config file %s', @@ -391,7 +459,7 @@ class Config(RawConfigParser): else: lines_before = [] - sections = {} # type: Dict[str, List[int]] + sections: Dict[str, List[int]] = {} duplicate_section = False current_section = '' current_line = 0 @@ -418,7 +486,7 @@ class Config(RawConfigParser): return (sections, lines_before) def set_and_save(self, option: str, value: ConfigValue, - section=DEFSECTION) -> Tuple[str, str]: + section=USE_DEFAULT_SECTION) -> Tuple[str, str]: """ set the value in the configuration then save it to the file @@ -426,10 +494,12 @@ class Config(RawConfigParser): # 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 section == USE_DEFAULT_SECTION: + section = self.default_section + if isinstance(value, str) and value == "toggle": + current = self.getbool(option, section) if isinstance(current, bool): - value = str(not current) + value = str(not current).lower() else: if current.lower() == "false": value = "true" @@ -440,51 +510,60 @@ class Config(RawConfigParser): 'Could not toggle option: %s.' ' Current value is %s.' % (option, current or "empty"), 'Warning') + value = str(value) if self.has_section(section): - RawConfigParser.set(self, section, option, value) + self.configparser.set(section, option, value) else: self.add_section(section) - RawConfigParser.set(self, section, option, value) + self.configparser.set(section, option, value) if not self.write_in_file(section, option, value): return ('Unable to write in the config file', 'Error') + if isinstance(option, str) and 'password' in option and 'eval_password' not in option: + value = '********' return ("%s=%s" % (option, value), 'Info') def remove_and_save(self, option: str, - section=DEFSECTION) -> Tuple[str, str]: + section=USE_DEFAULT_SECTION) -> Tuple[str, str]: """ Remove an option and then save it the config file """ + if section == USE_DEFAULT_SECTION: + section = self.default_section if self.has_section(section): - RawConfigParser.remove_option(self, section, option) + self.configparser.remove_option(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: str, value: ConfigValue, section=DEFSECTION): + def silent_set(self, option: str, value: ConfigValue, section=USE_DEFAULT_SECTION): """ Set a value, save, and return True on success and False on failure """ + if section == USE_DEFAULT_SECTION: + section = self.default_section if self.has_section(section): - RawConfigParser.set(self, section, option, value) + self.configparser.set(section, option, str(value)) else: self.add_section(section) - RawConfigParser.set(self, section, option, value) - return self.write_in_file(section, option, value) + self.configparser.set(section, option, str(value)) + return self.write_in_file(section, option, str(value)) - def set(self, option: str, value: ConfigValue, section=DEFSECTION): + def set(self, option: str, value: ConfigValue, section=USE_DEFAULT_SECTION): """ Set the value of an option temporarily """ + if section == USE_DEFAULT_SECTION: + section = self.default_section try: - RawConfigParser.set(self, section, option, value) + self.configparser.set(section, option, str(value)) except NoSectionError: pass - def to_dict(self) -> Dict[str, Dict[str, ConfigValue]]: + def to_dict(self) -> Dict[str, Dict[str, Optional[ConfigValue]]]: """ Returns a dict of the form {section: {option: value, option: value}, …} """ - res = {} # Dict[str, Dict[str, ConfigValue]] + res: Dict[str, Dict[str, Optional[ConfigValue]]] = {} for section in self.sections(): res[section] = {} for option in self.options(section): @@ -518,10 +597,10 @@ def file_ok(filepath: Path) -> bool: return bool(val) -def get_image_cache() -> Path: +def get_image_cache() -> Optional[Path]: if not config.get('extract_inline_images'): return None - tmp_dir = config.get('tmp_image_dir') + tmp_dir = config.getstr('tmp_image_dir') if tmp_dir: return Path(tmp_dir) return xdg.CACHE_HOME / 'images' @@ -560,43 +639,11 @@ def check_config(): print(' \033[31m%s\033[0m' % option) -def run_cmdline_args(): - "Parse the command line arguments" - global options - options = parse_args(xdg.CONFIG_HOME) - - # Copy a default file if none exists - if not options.filename.is_file(): - try: - options.filename.parent.mkdir(parents=True, exist_ok=True) - except OSError as e: - sys.stderr.write( - 'Poezio was unable to create the config directory: %s\n' % e) - sys.exit(1) - default = Path(__file__).parent / '..' / 'data' / 'default_config.cfg' - other = Path( - pkg_resources.resource_filename('poezio', 'default_config.cfg')) - if default.is_file(): - copy2(str(default), str(options.filename)) - elif other.is_file(): - copy2(str(other), str(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 options.filename.exists(): - options.filename.chmod(options.filename.stat().st_mode - | stat.S_IWUSR) - - global firstrun - firstrun = True - - -def create_global_config(): +def create_global_config(filename): "Create the global config object, or crash" try: global config - config = Config(options.filename, DEFAULT_CONFIG) + config = Config(filename, DEFAULT_CONFIG) except: import traceback sys.stderr.write('Poezio was unable to read or' @@ -605,11 +652,13 @@ def create_global_config(): sys.exit(1) -def setup_logging(): +def setup_logging(debug_file=''): "Change the logging config according to the cmdline options and config" global LOG_DIR LOG_DIR = config.get('log_dir') LOG_DIR = Path(LOG_DIR).expanduser() if LOG_DIR else xdg.DATA_HOME / 'logs' + from copy import deepcopy + logging_config = deepcopy(LOGGING_CONFIG) if config.get('log_errors'): try: LOG_DIR.mkdir(parents=True, exist_ok=True) @@ -617,8 +666,8 @@ def setup_logging(): # We can’t really log any error here, because logging isn’t setup yet. pass else: - LOGGING_CONFIG['root']['handlers'].append('error') - LOGGING_CONFIG['handlers']['error'] = { + logging_config['root']['handlers'].append('error') + logging_config['handlers']['error'] = { 'level': 'ERROR', 'class': 'logging.FileHandler', 'filename': str(LOG_DIR / 'errors.log'), @@ -626,37 +675,26 @@ def setup_logging(): } logging.disable(logging.WARNING) - if options.debug: - LOGGING_CONFIG['root']['handlers'].append('debug') - LOGGING_CONFIG['handlers']['debug'] = { + if debug_file: + logging_config['root']['handlers'].append('debug') + logging_config['handlers']['debug'] = { 'level': 'DEBUG', 'class': 'logging.FileHandler', - 'filename': options.debug, + 'filename': debug_file, 'formatter': 'simple', } logging.disable(logging.NOTSET) - if LOGGING_CONFIG['root']['handlers']: - logging.config.dictConfig(LOGGING_CONFIG) + if logging_config['root']['handlers']: + logging.config.dictConfig(logging_config) else: logging.disable(logging.ERROR) 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 poezio.common import safeJID as JID - global safeJID - safeJID = JID - LOGGING_CONFIG = { 'version': 1, - 'disable_existing_loggers': True, + 'disable_existing_loggers': False, 'formatters': { 'simple': { 'format': '%(asctime)s %(levelname)s:%(module)s:%(message)s' @@ -670,21 +708,8 @@ LOGGING_CONFIG = { } } -# 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 # type: Optional[Config] - -# The logger object for this module -log = None # type: Optional[logging.Logger] - -# The command-line options -options = None - -# delayed import from common.py -safeJID = None # type: Optional[Callable] +# Global config object. Is setup for real in poezio.py +config = Config(Path('/dev/null')) # the global log dir LOG_DIR = Path() diff --git a/poezio/connection.py b/poezio/connection.py index 57254069..503d9169 100644 --- a/poezio/connection.py +++ b/poezio/connection.py @@ -3,7 +3,7 @@ # 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. +# it under the terms of the GPL-3.0+ license. See the COPYING file. """ Defines the Connection class """ @@ -16,8 +16,10 @@ import subprocess import sys import base64 import random +from pathlib import Path import slixmpp +from slixmpp import JID, InvalidJID from slixmpp.xmlstream import ET from slixmpp.plugins.xep_0184 import XEP_0184 from slixmpp.plugins.xep_0030 import DiscoInfo @@ -26,8 +28,7 @@ from slixmpp.util import FileSystemCache from poezio import common from poezio import fixes from poezio import xdg -from poezio.common import safeJID -from poezio.config import config, options +from poezio.config import config class Connection(slixmpp.ClientXMPP): @@ -37,25 +38,25 @@ class Connection(slixmpp.ClientXMPP): """ __init = False - def __init__(self): - keyfile = config.get('keyfile') - certfile = config.get('certfile') + def __init__(self, custom_version=''): + keyfile = config.getstr('keyfile') + certfile = config.getstr('certfile') - device_id = config.get('device_id') + device_id = config.getstr('device_id') if not device_id: rng = random.SystemRandom() device_id = base64.urlsafe_b64encode( rng.getrandbits(24).to_bytes(3, 'little')).decode('ascii') config.set_and_save('device_id', device_id) - if config.get('jid'): + if config.getstr('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 = config.get('jid') - password = config.get('password') - eval_password = config.get('eval_password') + jid = config.getstr('jid') + password = config.getstr('password') + eval_password = config.getstr('eval_password') if not password and not eval_password and not (keyfile and certfile): password = getpass.getpass() @@ -79,25 +80,29 @@ class Connection(slixmpp.ClientXMPP): '\n') else: # anonymous auth self.anon = True - jid = config.get('server') + jid = config.getstr('server') password = None - jid = safeJID(jid) + try: + jid = JID(jid) + except InvalidJID: + sys.stderr.write('Invalid jid option: "%s" is not a valid JID\n' % jid) + sys.exit(1) jid.resource = '%s-%s' % ( jid.resource, device_id) if jid.resource else 'poezio-%s' % device_id # TODO: use the system language slixmpp.ClientXMPP.__init__( - self, jid, password, lang=config.get('lang')) + self, jid, password, lang=config.getstr('lang')) - force_encryption = config.get('force_encryption') + force_encryption = config.getbool('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') + self.keyfile = keyfile + self.certfile = certfile if keyfile and not certfile: log.error( 'keyfile is present in configuration file without certfile') @@ -106,15 +111,18 @@ class Connection(slixmpp.ClientXMPP): 'certfile is present in configuration file without keyfile') self.core = None - self.auto_reconnect = config.get('auto_reconnect') + self.auto_reconnect = config.getbool('auto_reconnect') 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( + self.ciphers = config.getstr( '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') + self.ca_certs = None + ca_certs = config.getlist('ca_cert_path') + if ca_certs and ca_certs != ['']: + self.ca_certs = list(map(Path, config.getlist('ca_cert_path'))) + interval = config.getint('whitespace_interval') if int(interval) > 0: self.whitespace_keepalive_interval = int(interval) else: @@ -152,33 +160,21 @@ class Connection(slixmpp.ClientXMPP): # 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( + self.plugin['xep_0184'].auto_ack = config.getbool('ack_message_receipts') + self.plugin['xep_0184'].auto_request = config.getbool( 'request_message_receipts') self.register_plugin('xep_0191') - if config.get('enable_smacks'): + if config.getbool('enable_smacks'): 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'): + if config.getbool('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'): + if config.getbool('send_poezio_info'): + info = {'name': 'poezio', 'version': custom_version} + if config.getbool('send_os_info'): info['os'] = common.get_os_info() self.plugin['xep_0030'].set_identities(identities={('client', 'console', @@ -190,7 +186,7 @@ class Connection(slixmpp.ClientXMPP): 'console', None, '')}) self.register_plugin('xep_0092', pconfig=info) - if config.get('send_time'): + if config.getbool('send_time'): self.register_plugin('xep_0202') self.register_plugin('xep_0224') self.register_plugin('xep_0231') @@ -199,18 +195,20 @@ class Connection(slixmpp.ClientXMPP): self.register_plugin('xep_0280') self.register_plugin('xep_0297') self.register_plugin('xep_0308') - self.register_plugin('xep_0319') + self.register_plugin('xep_0313') self.register_plugin('xep_0334') self.register_plugin('xep_0352') try: self.register_plugin('xep_0363') - except SyntaxError: - log.error('Failed to load HTTP File Upload plugin, it can only be ' - 'used on Python 3.5+') except slixmpp.plugins.base.PluginNotFound: log.error('Failed to load HTTP File Upload plugin, it can only be ' 'used with aiohttp installed') self.register_plugin('xep_0380') + try: + self.register_plugin('xep_0454') + except slixmpp.plugins.base.PluginNotFound: + log.error('Failed to load Media Sharing plugin, ' + 'it requires slixmpp 1.8.2.') self.init_plugins() def set_keepalive_values(self, option=None, value=None): @@ -223,8 +221,8 @@ class Connection(slixmpp.ClientXMPP): # 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') + ping_interval = config.getint('connection_check_interval') + timeout_delay = config.getint('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 @@ -241,7 +239,7 @@ class Connection(slixmpp.ClientXMPP): """ Connect and process events. """ - custom_host = config.get('custom_host') + custom_host = config.getstr('custom_host') custom_port = config.get('custom_port', 5222) if custom_port == -1: custom_port = 5222 diff --git a/poezio/contact.py b/poezio/contact.py index 27b0598c..90f34c7e 100644 --- a/poezio/contact.py +++ b/poezio/contact.py @@ -3,7 +3,7 @@ # 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. +# it under the terms of the GPL-3.0+ license. See the COPYING file. """ Defines the Resource and Contact classes, which are used in the roster. @@ -11,10 +11,17 @@ the roster. from collections import defaultdict import logging -from typing import Dict, Iterator, List, Optional, Union - -from poezio.common import safeJID -from slixmpp import JID +from typing import ( + Any, + Dict, + Iterator, + List, + Optional, + Union, +) + +from slixmpp import InvalidJID, JID +from slixmpp.roster import RosterItem log = logging.getLogger(__name__) @@ -30,8 +37,8 @@ class Resource: data: the dict to use as a source """ # Full JID - self._jid = jid # type: str - self._data = data # type: Dict[str, Union[str, int]] + self._jid: str = jid + self._data: Dict[str, Union[str, int]] = data @property def jid(self) -> str: @@ -39,15 +46,18 @@ class Resource: @property def priority(self) -> int: - return self._data.get('priority') or 0 + try: + return int(self._data.get('priority', 0)) + except Exception: + return 0 @property def presence(self) -> str: - return self._data.get('show') or '' + return str(self._data.get('show')) or '' @property def status(self) -> str: - return self._data.get('status') or '' + return str(self._data.get('status')) or '' def __repr__(self) -> str: return '<%s>' % self._jid @@ -65,19 +75,16 @@ class Contact: to get the resource with the highest priority, etc """ - def __init__(self, item): + def __init__(self, item: RosterItem): """ item: a slixmpp RosterItem pointing to that contact """ self.__item = item - self.folded_states = defaultdict(lambda: True) # type: Dict[str, bool] + self.folded_states: Dict[str, bool] = defaultdict(lambda: True) self._name = '' self.avatar = None self.error = None - self.tune = {} # type: Dict[str, str] - self.gaming = {} # type: Dict[str, str] - self.mood = '' - self.activity = '' + self.rich_presence: Dict[str, Any] = defaultdict(lambda: None) @property def groups(self) -> List[str]: @@ -90,7 +97,7 @@ class Contact: return self.__item.jid @property - def name(self): + def name(self) -> str: """The name of the contact or an empty string.""" return self.__item['name'] or self._name or '' @@ -100,26 +107,27 @@ class Contact: self._name = value @property - def ask(self): + def ask(self) -> Optional[str]: if self.__item['pending_out']: return 'asked' + return None @property - def pending_in(self): + def pending_in(self) -> bool: """We received a subscribe stanza from this contact.""" return self.__item['pending_in'] @pending_in.setter - def pending_in(self, value): + def pending_in(self, value: bool): self.__item['pending_in'] = value @property - def pending_out(self): + def pending_out(self) -> bool: """We sent a subscribe stanza to this contact.""" return self.__item['pending_out'] @pending_out.setter - def pending_out(self, value): + def pending_out(self, value: bool): self.__item['pending_out'] = value @property @@ -134,8 +142,12 @@ class Contact: return self.__item['subscription'] def __contains__(self, value): - return value in self.__item.resources or safeJID( - value).resource in self.__item.resources + try: + resource = JID(value).resource + except InvalidJID: + resource = None + return value in self.__item.resources or \ + (resource is not None and resource in self.__item.resources) def __len__(self) -> int: """Number of resources""" @@ -147,7 +159,10 @@ class Contact: def __getitem__(self, key) -> Optional[Resource]: """Return the corresponding Resource object, or None""" - res = safeJID(key).resource + try: + res = JID(key).resource + except InvalidJID: + return None resources = self.__item.resources item = resources.get(res, None) or resources.get(key, None) return Resource(key, item) if item else None diff --git a/poezio/core/command_defs.py b/poezio/core/command_defs.py new file mode 100644 index 00000000..770b3492 --- /dev/null +++ b/poezio/core/command_defs.py @@ -0,0 +1,452 @@ +from typing import Callable, List, Optional + +from poezio.core.commands import CommandCore +from poezio.core.completions import CompletionCore +from poezio.plugin_manager import PluginManager +from poezio.types import TypedDict + + +CommandDict = TypedDict( + "CommandDict", + { + "name": str, + "func": Callable, + "shortdesc": str, + "desc": str, + "usage": str, + "completion": Optional[Callable], + }, + total=False, +) + + +def get_commands(commands: CommandCore, completions: CompletionCore, plugin_manager: PluginManager) -> List[CommandDict]: + """ + Get the set of default poezio commands. + """ + return [ + { + "name": "help", + "func": commands.help, + "usage": "[command]", + "shortdesc": "\\_o< KOIN KOIN KOIN", + "completion": completions.help, + }, + { + "name": "join", + "func": commands.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": completions.join, + }, + { + "name": "exit", + "func": commands.quit, + "desc": "Just disconnect from the server and exit poezio.", + "shortdesc": "Exit poezio.", + }, + { + "name": "quit", + "func": commands.quit, + "desc": "Just disconnect from the server and exit poezio.", + "shortdesc": "Exit poezio.", + }, + { + "name": "next", + "func": commands.rotate_rooms_right, + "shortdesc": "Go to the next room.", + }, + { + "name": "prev", + "func": commands.rotate_rooms_left, + "shortdesc": "Go to the previous room.", + }, + { + "name": "win", + "func": commands.win, + "usage": "<number or name>", + "shortdesc": "Go to the specified room", + "completion": completions.win, + }, + { + "name": "w", + "func": commands.win, + "usage": "<number or name>", + "shortdesc": "Go to the specified room", + "completion": completions.win, + }, + { + "name": "wup", + "func": commands.wup, + "usage": "<prefix>", + "shortdesc": "Go to the tab whose name uniquely starts with prefix", + "completion": completions.win, + }, + { + "name": "move_tab", + "func": commands.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": completions.move_tab, + }, + { + "name": "destroy_room", + "func": commands.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, + }, + { + "name": "status", + "func": commands.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": completions.status, + }, + { + "name": "show", + "func": commands.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": completions.status, + }, + { + "name": "bookmark_local", + "func": commands.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": completions.bookmark_local, + }, + { + "name": "bookmark", + "func": commands.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": completions.bookmark, + }, + { + "name": "accept", + "func": commands.accept, + "usage": "[jid]", + "desc": ( + "Allow the provided JID (or the selected contact " + "in your roster), to see your presence." + ), + "shortdesc": "Allow a user your presence.", + "completion": completions.roster_barejids, + }, + { + "name": "add", + "func": commands.add, + "usage": "<jid>", + "desc": ( + "Add the specified JID to your roster, ask them to" + " allow you to see his presence, and allow them to" + " see your presence." + ), + "shortdesc": "Add a user to your roster.", + }, + { + "name": "deny", + "func": commands.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 their roster." + ), + "shortdesc": "Deny a user your presence.", + "completion": completions.roster_barejids, + }, + { + "name": "remove", + "func": commands.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 a user from your roster.", + "completion": completions.remove, + }, + { + "name": "reconnect", + "func": commands.command_reconnect, + "usage": "[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.", + }, + { + "name": "set", + "func": commands.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": completions.set, + }, + { + "name": "set_default", + "func": commands.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": completions.set_default, + }, + { + "name": "toggle", + "func": commands.toggle, + "usage": "<option>", + "desc": "Shortcut for /set <option> toggle", + "shortdesc": "Toggle an option", + "completion": completions.toggle, + }, + { + "name": "theme", + "func": commands.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": completions.theme, + }, + { + "name": "list", + "func": commands.list, + "usage": "[server]", + "desc": "Get the list of public rooms" " on the specified server.", + "shortdesc": "List the rooms.", + "completion": completions.list, + }, + { + "name": "message", + "func": commands.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": completions.message, + }, + { + "name": "version", + "func": commands.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": completions.version, + }, + { + "name": "server_cycle", + "func": commands.server_cycle, + "usage": "[domain] [message]", + "desc": "Disconnect and reconnect in all the rooms in domain.", + "shortdesc": "Cycle a range of rooms", + "completion": completions.server_cycle, + }, + { + "name": "bind", + "func": commands.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": completions.bind, + "shortdesc": "Bind a key to another key.", + }, + { + "name": "load", + "func": commands.load, + "usage": "<plugin> [<otherplugin> …]", + "shortdesc": "Load the specified plugin(s)", + "completion": plugin_manager.completion_load, + }, + { + "name": "unload", + "func": commands.unload, + "usage": "<plugin> [<otherplugin> …]", + "shortdesc": "Unload the specified plugin(s)", + "completion": plugin_manager.completion_unload, + }, + { + "name": "plugins", + "func": commands.plugins, + "shortdesc": "Show the plugins in use.", + }, + { + "name": "presence", + "func": commands.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": completions.presence, + }, + { + "name": "rawxml", + "func": commands.rawxml, + "usage": "<xml>", + "shortdesc": "Send a custom xml stanza.", + }, + { + "name": "invite", + "func": commands.invite, + "usage": "<jid> <room> [reason]", + "desc": "Invite jid in room with reason.", + "shortdesc": "Invite someone in a room.", + "completion": completions.invite, + }, + { + "name": "impromptu", + "func": commands.impromptu, + "usage": "<jid> [jid ...]", + "desc": "Invite specified JIDs into a newly created room.", + "shortdesc": "Invite specified JIDs into newly created room.", + "completion": completions.impromptu, + }, + { + "name": "invitations", + "func": commands.invitations, + "shortdesc": "Show the pending invitations.", + }, + { + "name": "bookmarks", + "func": commands.bookmarks, + "shortdesc": "Show the current bookmarks.", + }, + { + "name": "remove_bookmark", + "func": commands.remove_bookmark, + "usage": "[jid]", + "desc": "Remove the specified bookmark, or the " + "bookmark on the current tab, if any.", + "shortdesc": "Remove a bookmark", + "completion": completions.remove_bookmark, + }, + { + "name": "xml_tab", + "func": commands.xml_tab, + "shortdesc": "Open an XML tab.", + }, + { + "name": "runkey", + "func": commands.runkey, + "usage": "<key>", + "shortdesc": "Execute the action defined for <key>.", + "completion": completions.runkey, + }, + { + "name": "self", + "func": commands.self_, + "shortdesc": "Remind you of who you are.", + }, + { + "name": "last_activity", + "func": commands.last_activity, + "usage": "<jid>", + "desc": "Informs you of the last activity of a JID.", + "shortdesc": "Get the activity of someone.", + "completion": completions.last_activity, + }, + { + "name": "ad-hoc", + "func": commands.adhoc, + "usage": "<jid>", + "shortdesc": "List available ad-hoc commands on the given jid", + }, + { + "name": "reload", + "func": commands.reload, + "shortdesc": "Reload the config. You can achieve the same by " + "sending SIGUSR1 to poezio.", + }, + { + "name": "debug", + "func": commands.debug, + "usage": "[debug_filename]", + "shortdesc": "Enable or disable debug logging according to the " + "presence of [debug_filename].", + }, + ] diff --git a/poezio/core/commands.py b/poezio/core/commands.py index 86df9a93..fe91ca67 100644 --- a/poezio/core/commands.py +++ b/poezio/core/commands.py @@ -2,38 +2,46 @@ Global commands which are to be linked to the Core class """ -import logging - -log = logging.getLogger(__name__) - import asyncio -from xml.etree import cElementTree as ET +from urllib.parse import unquote +from xml.etree import ElementTree as ET +from typing import List, Optional, Tuple +import logging -from slixmpp.exceptions import XMPPError +from slixmpp import JID, InvalidJID +from slixmpp.exceptions import XMPPError, IqError, IqTimeout from slixmpp.xmlstream.xmlstream import NotConnectedError from slixmpp.xmlstream.stanzabase import StanzaBase from slixmpp.xmlstream.handler import Callback from slixmpp.xmlstream.matcher import StanzaPath -from poezio import common -from poezio import pep -from poezio import tabs +from poezio import common, config as config_module, tabs, multiuserchat as muc from poezio.bookmarks import Bookmark -from poezio.common import safeJID -from poezio.config import config, DEFAULT_CONFIG, options as config_opts -from poezio import multiuserchat as muc +from poezio.config import config, DEFAULT_CONFIG +from poezio.contact import Contact, Resource +from poezio.decorators import deny_anonymous from poezio.plugin import PluginConfig from poezio.roster import roster from poezio.theming import dump_tuple, get_theme from poezio.decorators import command_args_parser - from poezio.core.structs import Command, POSSIBLE_SHOW +log = logging.getLogger(__name__) + + class CommandCore: def __init__(self, core): self.core = core + @command_args_parser.ignored + def rotate_rooms_left(self, args=None): + self.core.rotate_rooms_left() + + @command_args_parser.ignored + def rotate_rooms_right(self, args=None): + self.core.rotate_rooms_right() + @command_args_parser.quoted(0, 1) def help(self, args): """ @@ -132,7 +140,7 @@ class CommandCore: current.send_chat_state('inactive') for tab in self.core.tabs: if isinstance(tab, tabs.MucTab) and tab.joined: - muc.change_show(self.core.xmpp, tab.name, tab.own_nick, show, + muc.change_show(self.core.xmpp, tab.jid, tab.own_nick, show, msg) if hasattr(tab, 'directed_presence'): del tab.directed_presence @@ -150,7 +158,7 @@ class CommandCore: jid, ptype, status = args[0], args[1], args[2] if jid == '.' and isinstance(self.core.tabs.current_tab, tabs.ChatTab): - jid = self.core.tabs.current_tab.name + jid = self.core.tabs.current_tab.jid if ptype == 'available': ptype = None try: @@ -216,6 +224,20 @@ class CommandCore: return self.core.tabs.set_current_tab(match) + @command_args_parser.quoted(1) + def wup(self, args): + """ + /wup <prefix of name> + """ + if args is None: + return self.help('wup') + + prefix = args[0] + _, match = self.core.tabs.find_by_unique_prefix(prefix) + if match is None: + return + self.core.tabs.set_current_tab(match) + @command_args_parser.quoted(2) def move_tab(self, args): """ @@ -257,7 +279,7 @@ class CommandCore: self.core.refresh_window() @command_args_parser.quoted(0, 1) - def list(self, args): + def list(self, args: List[str]) -> None: """ /list [server] Opens a MucListTab containing the list of the room in the specified server @@ -265,51 +287,76 @@ class CommandCore: if args is None: return self.help('list') elif args: - jid = safeJID(args[0]) + try: + jid = JID(args[0]) + except InvalidJID: + return self.core.information('Invalid server %r' % jid, 'Error') else: if not isinstance(self.core.tabs.current_tab, tabs.MucTab): return self.core.information('Please provide a server', 'Error') - jid = safeJID(self.core.tabs.current_tab.name) + jid = self.core.tabs.current_tab.jid + if jid is None or not jid.domain: + return None + asyncio.create_task( + self._list_async(jid) + ) + + async def _list_async(self, jid: JID): + jid = JID(jid.domain) list_tab = tabs.MucListTab(self.core, jid) self.core.add_tab(list_tab, True) - cb = list_tab.on_muc_list_item_received - self.core.xmpp.plugin['xep_0030'].get_items(jid=jid, callback=cb) + iq = await self.core.xmpp.plugin['xep_0030'].get_items(jid=jid) + list_tab.on_muc_list_item_received(iq) @command_args_parser.quoted(1) - def version(self, args): + async def version(self, args): """ /version <jid> """ if args is None: return self.help('version') - jid = safeJID(args[0]) + try: + jid = JID(args[0]) + except InvalidJID: + return self.core.information( + 'Invalid JID for /version: %s' % args[0], + 'Error' + ) if jid.resource or jid not in roster or not roster[jid].resources: - self.core.xmpp.plugin['xep_0092'].get_version( - jid, callback=self.core.handler.on_version_result) + iq = await self.core.xmpp.plugin['xep_0092'].get_version(jid) + self.core.handler.on_version_result(iq) elif jid in roster: for resource in roster[jid].resources: - self.core.xmpp.plugin['xep_0092'].get_version( - resource.jid, callback=self.core.handler.on_version_result) + iq = await self.core.xmpp.plugin['xep_0092'].get_version( + resource.jid + ) + self.core.handler.on_version_result(iq) def _empty_join(self): tab = self.core.tabs.current_tab if not isinstance(tab, (tabs.MucTab, tabs.PrivateTab)): return (None, None) - room = safeJID(tab.name).bare + room = tab.jid.bare nick = tab.own_nick return (room, nick) - def _parse_join_jid(self, jid_string): + def _parse_join_jid(self, jid_string: str) -> Tuple[Optional[str], Optional[str]]: # we try to join a server directly - if jid_string.startswith('@'): - server_root = True - info = safeJID(jid_string[1:]) - else: - info = safeJID(jid_string) - server_root = False + server_root = False + if jid_string.startswith('xmpp:') and jid_string.endswith('?join'): + jid_string = unquote(jid_string[5:-5]) + try: + if jid_string.startswith('@'): + server_root = True + info = JID(jid_string[1:]) + else: + info = JID(jid_string) + server_root = False + except InvalidJID: + info = JID('') - set_nick = '' + set_nick: Optional[str] = '' if len(jid_string) > 1 and jid_string.startswith('/'): set_nick = jid_string[1:] elif info.resource: @@ -321,7 +368,7 @@ class CommandCore: if not isinstance(tab, tabs.MucTab): room, set_nick = (None, None) else: - room = tab.name + room = tab.jid.bare if not set_nick: set_nick = tab.own_nick else: @@ -331,14 +378,12 @@ class CommandCore: # check if the current room's name has a server if room.find('@') == -1 and not server_root: tab = self.core.tabs.current_tab - if isinstance(tab, tabs.MucTab): - if tab.name.find('@') != -1: - domain = safeJID(tab.name).domain - room += '@%s' % domain + if isinstance(tab, tabs.MucTab) and tab.jid.domain: + room += '@%s' % tab.jid.domain return (room, set_nick) @command_args_parser.quoted(0, 2) - def join(self, args): + async def join(self, args): """ /join [room][/nick] [password] """ @@ -350,7 +395,11 @@ class CommandCore: return # nothing was parsed room = room.lower() + + # Has the nick been specified explicitely when joining + config_nick = False if nick == '': + config_nick = True nick = self.core.own_nick # a password is provided @@ -377,10 +426,16 @@ class CommandCore: tab.password = password tab.join() - if config.get('bookmark_on_join'): - method = 'remote' if config.get( + if config.getbool('synchronise_open_rooms') and room not in self.core.bookmarks: + method = 'remote' if config.getbool( 'use_remote_bookmarks') else 'local' - self._add_bookmark('%s/%s' % (room, nick), True, password, method) + await self._add_bookmark( + room=room, + nick=nick if not config_nick else None, + autojoin=True, + password=password, + method=method, + ) if tab == self.core.tabs.current_tab: tab.refresh() @@ -391,57 +446,99 @@ class CommandCore: """ /bookmark_local [room][/nick] [password] """ - if not args and not isinstance(self.core.tabs.current_tab, - tabs.MucTab): + tab = self.core.tabs.current_tab + if not args and not isinstance(tab, tabs.MucTab): return + + room, nick = self._parse_join_jid(args[0] if args else '') password = args[1] if len(args) > 1 else None - jid = args[0] if args else None - self._add_bookmark(jid, True, password, 'local') + if not room: + room = tab.jid.bare + if password is None and tab.password is not None: + password = tab.password + + asyncio.create_task( + self._add_bookmark( + room=room, + nick=nick, + autojoin=True, + password=password, + method='local', + ) + ) @command_args_parser.quoted(0, 3) def bookmark(self, args): """ /bookmark [room][/nick] [autojoin] [password] """ - if not args and not isinstance(self.core.tabs.current_tab, - tabs.MucTab): + tab = self.core.tabs.current_tab + if not args and not isinstance(tab, tabs.MucTab): return - jid = args[0] if args else '' + room, nick = self._parse_join_jid(args[0] if args else '') password = args[2] if len(args) > 2 else None - if not config.get('use_remote_bookmarks'): - return self._add_bookmark(jid, True, password, 'local') - - if len(args) > 1: - autojoin = False if args[1].lower() != 'true' else True - else: - autojoin = True + method = 'remote' if config.getbool('use_remote_bookmarks') else 'local' + autojoin = (method == 'local' or + (len(args) > 1 and args[1].lower() == 'true')) + + if not room: + room = tab.jid.bare + if password is None and tab.password is not None: + password = tab.password + + asyncio.create_task( + self._add_bookmark(room, nick, autojoin, password, method) + ) + + async def _add_bookmark( + self, + room: str, + nick: Optional[str], + autojoin: bool, + password: str, + method: str, + ) -> None: + ''' + Adds a bookmark. + + Args: + room: room Jid. + nick: optional nick. Will always be added to the bookmark if + specified. This takes precedence over tab.own_nick which takes + precedence over core.own_nick (global config). + autojoin: set the bookmark to join automatically. + password: room password. + method: 'local' or 'remote'. + ''' + + + if room == '*': + return await self._add_wildcard_bookmarks(method) + + # Once we found which room to bookmark, find corresponding tab if it + # exists and fill nickname if none was specified and not default. + tab = self.core.tabs.by_name_and_class(room, tabs.MucTab) + if tab and isinstance(tab, tabs.MucTab) and \ + tab.joined and tab.own_nick != self.core.own_nick: + nick = nick or tab.own_nick - self._add_bookmark(jid, autojoin, password, 'remote') + # Validate / Normalize + try: + if not nick: + jid = JID(room) + else: + jid = JID('{}/{}'.format(room, nick)) + room = jid.bare + nick = jid.resource or None + except InvalidJID: + self.core.information(f'Invalid address for bookmark: {room}/{nick}', 'Error') + return - def _add_bookmark(self, jid, autojoin, password, method): - nick = None - if not jid: - tab = self.core.tabs.current_tab - roomname = tab.name - if tab.joined and tab.own_nick != self.core.own_nick: - nick = tab.own_nick - if password is None and tab.password is not None: - password = tab.password - elif jid == '*': - return self._add_wildcard_bookmarks(method) - else: - info = safeJID(jid) - roomname, nick = info.bare, info.resource - if roomname == '': - tab = self.core.tabs.current_tab - if not isinstance(tab, tabs.MucTab): - return - roomname = tab.name - bookmark = self.core.bookmarks[roomname] + bookmark = self.core.bookmarks[room] if bookmark is None: - bookmark = Bookmark(roomname) + bookmark = Bookmark(room) self.core.bookmarks.append(bookmark) bookmark.method = method bookmark.autojoin = autojoin @@ -451,15 +548,20 @@ class CommandCore: bookmark.password = password self.core.bookmarks.save_local() - self.core.bookmarks.save_remote(self.core.xmpp, - self.core.handler.on_bookmark_result) - - def _add_wildcard_bookmarks(self, method): + try: + result = await self.core.bookmarks.save_remote( + self.core.xmpp, + ) + self.core.handler.on_bookmark_result(result) + except (IqError, IqTimeout) as iq: + self.core.handler.on_bookmark_result(iq) + + async def _add_wildcard_bookmarks(self, method): new_bookmarks = [] for tab in self.core.get_tabs(tabs.MucTab): - bookmark = self.core.bookmarks[tab.name] + bookmark = self.core.bookmarks[tab.jid.bare] if not bookmark: - bookmark = Bookmark(tab.name, autojoin=True, method=method) + bookmark = Bookmark(tab.jid.bare, autojoin=True, method=method) new_bookmarks.append(bookmark) else: bookmark.method = method @@ -468,8 +570,11 @@ class CommandCore: new_bookmarks.extend(self.core.bookmarks.bookmarks) self.core.bookmarks.set(new_bookmarks) self.core.bookmarks.save_local() - self.core.bookmarks.save_remote(self.core.xmpp, - self.core.handler.on_bookmark_result) + try: + iq = await self.core.bookmarks.save_remote(self.core.xmpp) + self.core.handler.on_bookmark_result(iq) + except IqError as iq: + self.core.handler.on_bookmark_result(iq) @command_args_parser.ignored def bookmarks(self): @@ -486,33 +591,173 @@ class CommandCore: @command_args_parser.quoted(0, 1) def remove_bookmark(self, args): """/remove_bookmark [jid]""" + jid = None + if not args: + tab = self.core.tabs.current_tab + if isinstance(tab, tabs.MucTab): + jid = tab.jid.bare + else: + jid = args[0] + + asyncio.create_task( + self._remove_bookmark_routine(jid) + ) - def cb(success): - if success: + async def _remove_bookmark_routine(self, jid: str): + """Asynchronously remove a bookmark""" + if self.core.bookmarks[jid]: + self.core.bookmarks.remove(jid) + try: + await self.core.bookmarks.save(self.core.xmpp) self.core.information('Bookmark deleted', 'Info') - else: + except (IqError, IqTimeout): self.core.information('Error while deleting the bookmark', 'Error') + else: + self.core.information('No bookmark to remove', 'Info') + @deny_anonymous + @command_args_parser.quoted(0, 1) + def accept(self, args): + """ + Accept a JID. Authorize it AND subscribe to it + """ if not args: tab = self.core.tabs.current_tab - if isinstance(tab, tabs.MucTab) and self.core.bookmarks[tab.name]: - self.core.bookmarks.remove(tab.name) - self.core.bookmarks.save(self.core.xmpp, callback=cb) + RosterInfoTab = tabs.RosterInfoTab + if not isinstance(tab, RosterInfoTab): + return self.core.information('No JID specified', 'Error') else: - self.core.information('No bookmark to remove', 'Info') + item = tab.selected_row + if isinstance(item, Contact): + jid = item.bare_jid + else: + return self.core.information('No subscription to accept', 'Warning') else: - if self.core.bookmarks[args[0]]: - self.core.bookmarks.remove(args[0]) - self.core.bookmarks.save(self.core.xmpp, callback=cb) + try: + jid = JID(args[0]).bare + except InvalidJID: + return self.core.information('Invalid JID for /accept: %s' % args[0], 'Error') + jid = JID(jid) + nodepart = jid.user + # 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 self.core.information('No subscription to accept', 'Warning') + 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') + + @deny_anonymous + @command_args_parser.quoted(1) + def add(self, args): + """ + Add the specified JID to the roster, and automatically + accept the reverse subscription + """ + if args is None: + tab = self.core.tabs.current_tab + ConversationTab = tabs.ConversationTab + if isinstance(tab, ConversationTab): + jid = tab.general_jid + if jid in roster and roster[jid].subscription in ('to', 'both'): + return self.core.information('Already subscribed.', 'Roster') + roster.add(jid) + roster.modified() + return self.core.information('%s was added to the roster' % jid, 'Roster') else: - self.core.information('No bookmark to remove', 'Info') + return self.core.information('No JID specified', 'Error') + try: + jid = JID(args[0]).bare + except InvalidJID: + return self.core.information('Invalid JID for /add: %s' % args[0], 'Error') + 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') + + @deny_anonymous + @command_args_parser.quoted(0, 1) + def deny(self, args): + """ + /deny [jid] + Denies a JID from our roster + """ + jid = None + if not args: + tab = self.core.tabs.current_tab + if isinstance(tab, tabs.RosterInfoTab): + item = tab.roster_win.selected_row + if isinstance(item, Contact): + jid = item.bare_jid + else: + try: + jid = JID(args[0]).bare + except InvalidJID: + return self.core.information('Invalid JID for /deny: %s' % args[0], 'Error') + if jid not in [jid for jid in roster.jids()]: + jid = None + if jid is None: + self.core.information('No subscription to deny', 'Warning') + return + + contact = roster[jid] + if contact: + contact.unauthorize() + self.core.information('Subscription to %s was revoked' % jid, + 'Roster') + + @deny_anonymous + @command_args_parser.quoted(0, 1) + def remove(self, args): + """ + Remove the specified JID from the roster. i.e.: unsubscribe + from its presence, and cancel its subscription to our. + """ + jid = None + if args: + try: + jid = JID(args[0]).bare + except InvalidJID: + return self.core.information('Invalid JID for /remove: %s' % args[0], 'Error') + else: + tab = self.core.tabs.current_tab + if isinstance(tab, tabs.RosterInfoTab): + item = tab.roster_win.selected_row + if isinstance(item, Contact): + jid = item.bare_jid + if jid is None: + self.core.information('No roster item to remove', 'Error') + return + roster.remove(jid) + del roster[jid] + + @command_args_parser.ignored + def command_reconnect(self): + """ + /reconnect + """ + if self.core.xmpp.is_connected(): + self.core.disconnect(reconnect=True) + else: + self.core.xmpp.start() @command_args_parser.quoted(0, 3) def set(self, args): """ /set [module|][section] <option> [value] """ + if len(args) == 3 and args[1] == '=': + args = [args[0], args[2]] if args is None or len(args) == 0: config_dict = config.to_dict() lines = [] @@ -525,6 +770,9 @@ class CommandCore: theme.COLOR_INFORMATION_TEXT), }) for option_name, option_value in section.items(): + if isinstance(option_name, str) and \ + 'password' in option_name and 'eval_password' not in option_name: + option_value = '********' lines.append( '%s\x19%s}=\x19o%s' % (option_name, dump_tuple( @@ -533,6 +781,9 @@ class CommandCore: elif len(args) == 1: option = args[0] value = config.get(option) + if isinstance(option, str) and \ + 'password' in option and 'eval_password' not in option and value is not None: + value = '********' if value is None and '=' in option: args = option.split('=', 1) info = ('%s=%s' % (option, value), 'Info') @@ -553,7 +804,8 @@ class CommandCore: info = ('%s=%s' % (option, value), 'Info') else: possible_section = args[0] - if config.has_section(possible_section): + if (not config.has_option(section='Poezio', option=possible_section) + and config.has_section(possible_section)): section = possible_section option = args[1] value = config.get(option, section=section) @@ -580,7 +832,7 @@ class CommandCore: info = plugin_config.set_and_save(option, value, section) else: if args[0] == '.': - name = safeJID(self.core.tabs.current_tab.name).bare + name = self.core.tabs.current_tab.jid.bare if not name: self.core.information( 'Invalid tab to use the "." argument.', 'Error') @@ -632,137 +884,88 @@ class CommandCore: def server_cycle(self, args): """ Do a /cycle on each room of the given server. - If none, do it on the current tab + If none, do it on the server of the current tab """ tab = self.core.tabs.current_tab message = "" if args: - domain = args[0] + try: + domain = JID(args[0]).domain + except InvalidJID: + return self.core.information( + "Invalid server domain: %s" % args[0], + "Error" + ) if len(args) == 2: message = args[1] else: if isinstance(tab, tabs.MucTab): - domain = safeJID(tab.name).domain + domain = tab.jid.domain else: return self.core.information("No server specified", "Error") for tab in self.core.get_tabs(tabs.MucTab): - if tab.name.endswith(domain): + if tab.jid.domain == domain: tab.leave_room(message) tab.join() @command_args_parser.quoted(1) - def last_activity(self, args): + async def 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.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'] - 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.core.information(msg, 'Info') - if args is None: return self.help('last_activity') - jid = safeJID(args[0]) - self.core.xmpp.plugin['xep_0012'].get_last_activity( - jid, callback=callback) - - @command_args_parser.quoted(0, 2) - def mood(self, args): - """ - /mood [<mood> [text]] - """ - if not args: - return self.core.xmpp.plugin['xep_0107'].stop() - - mood = args[0] - if mood not in pep.MOODS: - return self.core.information( - '%s is not a correct value for a mood.' % mood, 'Error') - if len(args) == 2: - text = args[1] - else: - text = None - self.core.xmpp.plugin['xep_0107'].publish_mood( - mood, text, callback=dumb_callback) - - @command_args_parser.quoted(0, 3) - def activity(self, args): - """ - /activity [<general> [specific] [text]] - """ - length = len(args) - if not length: - return self.core.xmpp.plugin['xep_0108'].stop() + try: + jid = JID(args[0]) + except InvalidJID: + return self.core.information('Invalid JID for /last_activity: %s' % args[0], 'Error') - general = args[0] - if general not in pep.ACTIVITIES: - return self.core.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] + try: + iq = await self.core.xmpp.plugin['xep_0012'].get_last_activity(jid) + except IqError as error: + if error.etype == 'auth': + msg = 'You are not allowed to see the activity of %s' % jid else: - text = args[1] - elif length == 3: - specific = args[1] - text = args[2] - if specific and specific not in pep.ACTIVITIES[general]: - return self.core.information( - '%s is not a correct value ' - 'for an activity' % specific, 'Error') - self.core.xmpp.plugin['xep_0108'].publish_activity( - general, specific, text, callback=dumb_callback) - - @command_args_parser.quoted(0, 2) - def gaming(self, args): - """ - /gaming [<game name> [server address]] - """ - if not args: - return self.core.xmpp.plugin['xep_0196'].stop() - - name = args[0] - if len(args) > 1: - address = args[1] + msg = 'Error retrieving the activity of %s: %s' % (jid, error) + return self.core.information(msg, 'Error') + except IqTimeout: + return self.core.information('Timeout while retrieving the last activity of %s' % jid, 'Error') + + seconds = iq['last_activity']['seconds'] + status = iq['last_activity']['status'] + from_ = iq['from'] + if not from_.user: + msg = 'The uptime of %s is %s.' % ( + from_, common.parse_secs_to_str(seconds)) else: - address = None - return self.core.xmpp.plugin['xep_0196'].publish_gaming( - name=name, server_address=address, callback=dumb_callback) + msg = 'The last activity of %s was %s ago%s' % ( + from_, common.parse_secs_to_str(seconds), + (' and their last status was %s' % status) + if status else '') + self.core.information(msg, 'Info') @command_args_parser.quoted(2, 1, [None]) - def invite(self, args): + async def invite(self, args): """/invite <to> <room> [reason]""" if args is None: return self.help('invite') reason = args[2] - to = safeJID(args[0]) - room = safeJID(args[1]).bare - self.core.invite(to.full, room, reason=reason) - self.core.information('Invited %s to %s' % (to.bare, room), 'Info') + try: + to = JID(args[0]) + except InvalidJID: + self.core.information('Invalid JID specified for invite: %s' % args[0], 'Error') + return None + try: + room = JID(args[1]).bare + except InvalidJID: + self.core.information('Invalid room JID specified to invite: %s' % args[1], 'Error') + return None + result = await self.core.invite(to.full, room, reason=reason) + if result: + self.core.information('Invited %s to %s' % (to.bare, room), 'Info') @command_args_parser.quoted(1, 0) def impromptu(self, args: str) -> None: @@ -777,17 +980,23 @@ class CommandCore: jids.add(current_tab.general_jid) for jid in common.shell_split(' '.join(args)): - jids.add(safeJID(jid).bare) + try: + bare = JID(jid).bare + except InvalidJID: + return self.core.information('Invalid JID for /impromptu: %s' % args[0], 'Error') + jids.add(JID(bare)) - asyncio.ensure_future(self.core.impromptu(jids)) - self.core.information('Invited %s to a random room' % (' '.join(jids)), 'Info') + asyncio.create_task(self.core.impromptu(jids)) @command_args_parser.quoted(1, 1, ['']) def decline(self, args): """/decline <room@server.tld> [reason]""" if args is None: return self.help('decline') - jid = safeJID(args[0]) + try: + jid = JID(args[0]) + except InvalidJID: + return self.core.information('Invalid JID for /decline: %s' % args[0], 'Error') if jid.bare not in self.core.pending_invites: return reason = args[1] @@ -795,21 +1004,135 @@ class CommandCore: self.core.xmpp.plugin['xep_0045'].decline_invite( jid.bare, self.core.pending_invites[jid.bare], reason) + @command_args_parser.quoted(0, 1) + def block(self, args: List[str]) -> None: + """ + /block [jid] + + If a JID is specified, use it. Otherwise if in RosterInfoTab, use the + selected JID, if in ConversationsTab use the Tab's JID. + """ + + jid = None + if args: + try: + jid = JID(args[0]) + except InvalidJID: + self.core.information('Invalid JID %s' % args, 'Error') + return + + current_tab = self.core.tabs.current_tab + if jid is None: + if isinstance(current_tab, tabs.RosterInfoTab): + roster_win = self.core.tabs.by_name_and_class( + 'Roster', + tabs.RosterInfoTab, + ) + item = roster_win.selected_row + if isinstance(item, Contact): + jid = item.bare_jid + elif isinstance(item, Resource): + jid = JID(item.jid) + + chattabs = ( + tabs.ConversationTab, + tabs.StaticConversationTab, + tabs.DynamicConversationTab, + ) + if isinstance(current_tab, chattabs): + jid = JID(current_tab.jid.bare) + + if jid is None: + self.core.information('No specified JID to block', 'Error') + else: + asyncio.create_task(self._block_async(jid)) + + async def _block_async(self, jid: JID): + """Block a JID, asynchronously""" + try: + await self.core.xmpp.plugin['xep_0191'].block(jid) + return self.core.information('Blocked %s.' % jid, 'Info') + except (IqError, IqTimeout): + return self.core.information( + 'Could not block %s.' % jid, 'Error', + ) + + @command_args_parser.quoted(0, 1) + def unblock(self, args: List[str]) -> None: + """ + /unblock [jid] + """ + + item = self.core.tabs.by_name_and_class( + 'Roster', + tabs.RosterInfoTab, + ).selected_row + + jid = None + if args: + try: + jid = JID(args[0]) + except InvalidJID: + self.core.information('Invalid JID %s' % args, 'Error') + return + + current_tab = self.core.tabs.current_tab + if jid is None: + if isinstance(current_tab, tabs.RosterInfoTab): + roster_win = self.core.tabs.by_name_and_class( + 'Roster', + tabs.RosterInfoTab, + ) + item = roster_win.selected_row + if isinstance(item, Contact): + jid = item.bare_jid + elif isinstance(item, Resource): + jid = JID(item.jid) + + chattabs = ( + tabs.ConversationTab, + tabs.StaticConversationTab, + tabs.DynamicConversationTab, + ) + if isinstance(current_tab, chattabs): + jid = JID(current_tab.jid.bare) + + if jid is not None: + asyncio.create_task( + self._unblock_async(jid) + ) + else: + self.core.information('No specified JID to unblock', 'Error') + + async def _unblock_async(self, jid: JID): + """Unblock a JID, asynchrously""" + try: + await self.core.xmpp.plugin['xep_0191'].unblock(jid) + return self.core.information('Unblocked %s.' % jid, 'Info') + except (IqError, IqTimeout): + return self.core.information('Could not unblock the contact.', + 'Error') ### Commands without a completion in this class ### @command_args_parser.ignored def invitations(self): """/invitations""" - build = "" - for invite in self.core.pending_invites: - build += "%s by %s" % ( - invite, safeJID(self.core.pending_invites[invite]).bare) - if self.core.pending_invites: - build = "You are invited to the following rooms:\n" + build + build = [] + for room, inviter in self.core.pending_invites.items(): + try: + bare = JID(inviter).bare + except InvalidJID: + self.core.information( + f'Invalid JID found in /invitations: {inviter}', + 'Error' + ) + build.append(f'{room} by {bare}') + if build: + message = 'You are invited to the following rooms:\n' + ','.join(build) else: - build = "You do not have any pending invitations." - self.core.information(build, 'Info') + message = 'You do not have any pending invitations.' + self.core.information(message, 'Info') @command_args_parser.quoted(0, 1, [None]) def quit(self, args): @@ -821,32 +1144,51 @@ class CommandCore: return msg = args[0] - if config.get('enable_user_mood'): - self.core.xmpp.plugin['xep_0107'].stop() - if config.get('enable_user_activity'): - self.core.xmpp.plugin['xep_0108'].stop() - if config.get('enable_user_gaming'): - self.core.xmpp.plugin['xep_0196'].stop() self.core.save_config() self.core.plugin_manager.disable_plugins() - self.core.disconnect(msg) self.core.xmpp.add_event_handler( "disconnected", self.core.exit, disposable=True) + self.core.disconnect(msg) - @command_args_parser.quoted(0, 1, ['']) - def destroy_room(self, args): + @command_args_parser.quoted(0, 3, ['', '', '']) + def destroy_room(self, args: List[str]): """ - /destroy_room [JID] + /destroy_room [JID [reason [alternative room JID]]] """ - room = safeJID(args[0]).bare - if room: - muc.destroy_room(self.core.xmpp, room) - elif isinstance(self.core.tabs.current_tab, - tabs.MucTab) and not args[0]: - muc.destroy_room(self.core.xmpp, - self.core.tabs.current_tab.general_jid) + async def do_destroy(room: JID, reason: str, altroom: Optional[JID]): + try: + await self.core.xmpp['xep_0045'].destroy(room, reason, altroom) + except (IqError, IqTimeout) as e: + self.core.information('Unable to destroy room %s: %s' % (room, e), 'Info') + else: + self.core.information('Room %s destroyed' % room, 'Info') + + room: Optional[JID] + if not args[0] and isinstance(self.core.tabs.current_tab, tabs.MucTab): + room = self.core.tabs.current_tab.general_jid else: - self.core.information('Invalid JID: "%s"' % args[0], 'Error') + try: + room = JID(args[0]) + except InvalidJID: + room = None + else: + if room.resource: + room = None + + if room is None: + self.core.information('Invalid room JID: "%s"' % args[0], 'Error') + return + + reason = args[1] + altroom = None + if args[2]: + try: + altroom = JID(args[2]) + except InvalidJID: + self.core.information('Invalid alternative room JID: "%s"' % args[2], 'Error') + return + + asyncio.create_task(do_destroy(room, reason, altroom)) @command_args_parser.quoted(1, 1, ['']) def bind(self, args): @@ -903,11 +1245,17 @@ class CommandCore: exc_info=True) @command_args_parser.quoted(1, 256) - def load(self, args): + def load(self, args: List[str]) -> None: """ /load <plugin> [<otherplugin> …] # TODO: being able to load more than 256 plugins at once, hihi. """ + + usage = '/load <plugin> [<otherplugin> …]' + if not args: + self.core.information(usage, 'Error') + return + for plugin in args: self.core.plugin_manager.load(plugin) @@ -916,6 +1264,12 @@ class CommandCore: """ /unload <plugin> [<otherplugin> …] """ + + usage = '/unload <plugin> [<otherplugin> …]' + if not args: + self.core.information(usage, 'Error') + return + for plugin in args: self.core.plugin_manager.unload(plugin) @@ -929,20 +1283,23 @@ class CommandCore: list(self.core.plugin_manager.plugins.keys())), 'Info') @command_args_parser.quoted(1, 1) - def message(self, args): + async def message(self, args): """ /message <jid> [message] """ if args is None: return self.help('message') - jid = safeJID(args[0]) + try: + jid = JID(args[0]) + except InvalidJID: + return self.core.information('Invalid JID for /message: %s' % args[0], 'Error') if not jid.user and not jid.domain and not jid.resource: return self.core.information('Invalid JID.', 'Error') tab = self.core.get_conversation_by_jid( jid.full, False, fallback_barejid=False) muc = self.core.tabs.by_name_and_class(jid.bare, tabs.MucTab) if not tab and not muc: - tab = self.core.open_conversation_window(jid.full, focus=True) + tab = self.core.open_conversation_window(JID(jid.full), focus=True) elif muc: if jid.resource: tab = self.core.tabs.by_name_and_class(jid.full, @@ -956,7 +1313,7 @@ class CommandCore: else: self.core.focus_tab(tab) if len(args) == 2: - tab.command_say(args[1]) + await tab.command_say(args[1]) @command_args_parser.ignored def xml_tab(self): @@ -968,15 +1325,23 @@ class CommandCore: self.core.xml_tab = tab @command_args_parser.quoted(1) - def adhoc(self, args): + async def adhoc(self, args): if not args: return self.help('ad-hoc') - jid = safeJID(args[0]) + try: + jid = JID(args[0]) + except InvalidJID: + return self.core.information( + 'Invalid JID for ad-hoc command: %s' % args[0], + 'Error', + ) list_tab = tabs.AdhocCommandsListTab(self.core, jid) self.core.add_tab(list_tab, True) - cb = list_tab.on_list_received - self.core.xmpp.plugin['xep_0050'].get_commands( - jid=jid, local=False, callback=cb) + iq = await self.core.xmpp.plugin['xep_0050'].get_commands( + jid=jid, + local=False + ) + list_tab.on_list_received(iq) @command_args_parser.ignored def self_(self): @@ -990,7 +1355,7 @@ class CommandCore: 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)) + if show else 'available', nick, self.core.custom_version)) self.core.information(info, 'Info') @command_args_parser.ignored @@ -1000,6 +1365,16 @@ class CommandCore: """ self.core.reload_config() + @command_args_parser.raw + def debug(self, args): + """/debug [filename]""" + if not args.strip(): + config_module.setup_logging('') + self.core.information('Debug logging disabled!', 'Info') + elif args: + config_module.setup_logging(args) + self.core.information(f'Debug logging to {args} enabled!', 'Info') + def dumb_callback(*args, **kwargs): "mock callback" diff --git a/poezio/core/completions.py b/poezio/core/completions.py index 87bb2d47..084910a2 100644 --- a/poezio/core/completions.py +++ b/poezio/core/completions.py @@ -2,23 +2,23 @@ Completions for the global commands """ import logging - -log = logging.getLogger(__name__) - import os -from pathlib import Path from functools import reduce +from pathlib import Path +from typing import List, Optional + +from slixmpp import JID, InvalidJID from poezio import common -from poezio import pep from poezio import tabs from poezio import xdg -from poezio.common import safeJID from poezio.config import config from poezio.roster import roster from poezio.core.structs import POSSIBLE_SHOW, Completion +log = logging.getLogger(__name__) + class CompletionCore: def __init__(self, core): @@ -41,6 +41,19 @@ class CompletionCore: ' ', quotify=False) + def roster_barejids(self, the_input): + """Complete roster bare jids""" + jids = sorted( + str(contact.bare_jid) for contact in roster.contacts.values() + if contact.pending_in + ) + return Completion(the_input.new_completion, jids, 1, '', quotify=False) + + def remove(self, the_input): + """Completion for /remove""" + jids = [jid for jid in roster.jids()] + return Completion(the_input.auto_completion, jids, '', quotify=False) + def presence(self, the_input): """ Completion of /presence @@ -67,7 +80,7 @@ class CompletionCore: def theme(self, the_input): """ Completion for /theme""" - themes_dir = config.get('themes_dir') + themes_dir = config.getstr('themes_dir') themes_dir = Path(themes_dir).expanduser( ) if themes_dir else xdg.DATA_HOME / 'themes' try: @@ -109,9 +122,12 @@ class CompletionCore: 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] + try: + jid = JID(args[1]) + except InvalidJID: + jid = JID('') + if args[1].endswith('@'): + jid.user = args[1][:-1] relevant_rooms = [] relevant_rooms.extend(sorted(self.core.pending_invites.keys())) @@ -134,7 +150,8 @@ class CompletionCore: for tab in self.core.get_tabs(tabs.MucTab): if tab.joined: serv_list.append( - '%s@%s' % (jid.user, safeJID(tab.name).host)) + '%s@%s' % (jid.user, tab.general_jid.server) + ) serv_list.extend(relevant_rooms) return Completion( the_input.new_completion, serv_list, 1, quotify=True) @@ -161,8 +178,8 @@ class CompletionCore: muc_serv_list = [] for tab in self.core.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 tab.jid.server not in muc_serv_list: + muc_serv_list.append(tab.jid.server) if muc_serv_list: return Completion( the_input.new_completion, muc_serv_list, 1, quotify=False) @@ -198,14 +215,13 @@ class CompletionCore: if len(args) == 1: args.append('') - jid = safeJID(args[1]) - - if jid.server and (jid.resource or jid.full.endswith('/')): + try: + jid = JID(args[1]) tab = self.core.tabs.by_name_and_class(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') + nick = config.getstr('default_nick') if not nick: if default not in nicks: nicks.append(default) @@ -215,6 +231,8 @@ class CompletionCore: jids_list = ['%s/%s' % (jid.bare, nick) for nick in nicks] return Completion( the_input.new_completion, jids_list, 1, quotify=True) + except InvalidJID: + pass muc_list = [tab.name for tab in self.core.get_tabs(tabs.MucTab)] muc_list.sort() muc_list.append('*') @@ -284,7 +302,7 @@ class CompletionCore: rooms = [] for tab in self.core.get_tabs(tabs.MucTab): if tab.joined: - rooms.append(tab.name) + rooms.append(tab.jid.bare) rooms.sort() return Completion( the_input.new_completion, rooms, n, '', quotify=True) @@ -302,33 +320,6 @@ class CompletionCore: comp = sorted(onlines) + sorted(offlines) return Completion(the_input.new_completion, comp, n, quotify=True) - def 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 Completion( - 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 Completion(the_input.new_completion, l, n, quotify=True) - - def mood(self, the_input): - """Completion for /mood""" - n = the_input.get_argument_position(quoted=True) - if n == 1: - return Completion( - the_input.new_completion, - sorted(pep.MOODS.keys()), - 1, - quotify=True) - def last_activity(self, the_input): """ Completion for /last_activity <jid> @@ -346,8 +337,7 @@ class CompletionCore: """Completion for /server_cycle""" serv_list = set() for tab in self.core.get_tabs(tabs.MucTab): - serv = safeJID(tab.name).server - serv_list.add(serv) + serv_list.add(tab.jid.server) return Completion(the_input.new_completion, sorted(serv_list), 1, ' ') def set(self, the_input): @@ -442,14 +432,13 @@ class CompletionCore: return False if len(args) == 1: args.append('') - jid = safeJID(args[1]) - - if jid.server and (jid.resource or jid.full.endswith('/')): + try: + jid = JID(args[1]) tab = self.core.tabs.by_name_and_class(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') + nick = config.getstr('default_nick') if not nick: if default not in nicks: nicks.append(default) @@ -459,6 +448,45 @@ class CompletionCore: jids_list = ['%s/%s' % (jid.bare, nick) for nick in nicks] return Completion( the_input.new_completion, jids_list, 1, quotify=True) + except InvalidJID: + pass muc_list = [tab.name for tab in self.core.get_tabs(tabs.MucTab)] muc_list.append('*') return Completion(the_input.new_completion, muc_list, 1, quotify=True) + + def block(self, the_input) -> Optional[Completion]: + """ + Completion for /block + """ + if the_input.get_argument_position() == 1: + + current_tab = self.core.tabs.current_tab + chattabs = ( + tabs.ConversationTab, + tabs.StaticConversationTab, + tabs.DynamicConversationTab, + ) + tabjid: List[str] = [] + if isinstance(current_tab, chattabs): + tabjid = [current_tab.jid.bare] + + jids = [str(i) for i in roster.jids()] + jids += tabjid + return Completion( + the_input.new_completion, jids, 1, '', quotify=False) + return None + + def unblock(self, the_input) -> Optional[Completion]: + """ + Completion for /unblock + """ + + def on_result(iq): + if iq['type'] == 'error': + return None + l = sorted(str(item) for item in iq['blocklist']['items']) + return Completion(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 None diff --git a/poezio/core/core.py b/poezio/core/core.py index 9651a73b..6582402d 100644 --- a/poezio/core/core.py +++ b/poezio/core/core.py @@ -5,6 +5,8 @@ of everything; it also contains global commands, completions and event handlers but those are defined in submodules in order to avoir cluttering this file. """ +from __future__ import annotations + import logging import asyncio import curses @@ -13,29 +15,47 @@ import pipes import sys import shutil import time -import uuid from collections import defaultdict -from typing import Callable, Dict, List, Optional, Set, Tuple, Type -from xml.etree import cElementTree as ET -from functools import partial - -from slixmpp import JID +from typing import ( + Any, + Callable, + Dict, + List, + Optional, + Set, + Tuple, + Type, + TypeVar, + TYPE_CHECKING, +) +from xml.etree import ElementTree as ET +from pathlib import Path + +from slixmpp import Iq, JID, InvalidJID from slixmpp.util import FileSystemPerJidCache +from slixmpp.xmlstream.xmlstream import InvalidCABundle from slixmpp.xmlstream.handler import Callback -from slixmpp.exceptions import IqError, IqTimeout +from slixmpp.exceptions import IqError, IqTimeout, XMPPError from poezio import connection from poezio import decorators from poezio import events -from poezio import multiuserchat as muc -from poezio import tabs from poezio import theming from poezio import timed_events from poezio import windows - -from poezio.bookmarks import BookmarkList -from poezio.common import safeJID -from poezio.config import config, firstrun +from poezio import utils + +from poezio.bookmarks import ( + BookmarkList, + Bookmark, +) +from poezio.tabs import ( + Tab, XMLTab, ChatTab, ConversationTab, PrivateTab, MucTab, OneToOneTab, + GapTab, RosterInfoTab, StaticConversationTab, DataFormsTab, + DynamicConversationTab, STATE_PRIORITY +) +from poezio.common import get_error_message +from poezio.config import config from poezio.contact import Contact, Resource from poezio.daemon import Executor from poezio.fifo import Fifo @@ -46,45 +66,92 @@ from poezio.size_manager import SizeManager from poezio.user import User from poezio.text_buffer import TextBuffer from poezio.timed_events import DelayedEvent -from poezio.theming import get_theme from poezio import keyboard, xdg from poezio.core.completions import CompletionCore from poezio.core.tabs import Tabs from poezio.core.commands import CommandCore +from poezio.core.command_defs import get_commands from poezio.core.handlers import HandlerCore -from poezio.core.structs import POSSIBLE_SHOW, DEPRECATED_ERRORS, \ - ERROR_AND_STATUS_CODES, Command, Status +from poezio.core.structs import ( + Command, + Status, + POSSIBLE_SHOW, +) + +from poezio.ui.types import ( + PersistentInfoMessage, + UIMessage, +) + +if TYPE_CHECKING: + from _curses import _CursesWindow # pylint: disable=no-name-in-module log = logging.getLogger(__name__) +T = TypeVar('T', bound=Tab) + class Core: """ “Main” class of poezion """ - def __init__(self): + custom_version: str + firstrun: bool + completion: CompletionCore + command: CommandCore + handler: HandlerCore + bookmarks: BookmarkList + status: Status + commands: Dict[str, Command] + room_number_jump: List[str] + initial_joins: List[JID] + pending_invites: Dict[str, str] + configuration_change_handlers: Dict[str, List[Callable[..., None]]] + own_nick: str + connection_time: float + xmpp: connection.Connection + avatar_cache: FileSystemPerJidCache + plugins_autoloaded: bool + previous_tab_nb: int + tabs: Tabs + size: SizeManager + plugin_manager: PluginManager + events: events.EventHandler + legitimate_disconnect: bool + information_buffer: TextBuffer + information_win_size: int + stdscr: Optional[_CursesWindow] + xml_buffer: TextBuffer + xml_tab: Optional[XMLTab] + last_stream_error: Optional[Tuple[float, XMPPError]] + remote_fifo: Optional[Fifo] + key_func: KeyDict + tab_win: windows.GlobalInfoBar + left_tab_win: Optional[windows.VerticalGlobalInfoBar] + + def __init__(self, custom_version: str, firstrun: bool): self.completion = CompletionCore(self) self.command = CommandCore(self) self.handler = HandlerCore(self) + self.firstrun = firstrun # 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.last_stream_error = None 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 = connection.Connection() + status = config.getstr('status') + status = POSSIBLE_SHOW.get(status) or '' + self.status = Status(show=status, message=config.getstr('status_message')) + self.custom_version = custom_version + self.xmpp = connection.Connection(custom_version) 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 self.avatar_cache = FileSystemPerJidCache( str(xdg.CACHE_HOME), 'avatars', binary=True) @@ -92,13 +159,8 @@ class Core: # 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.information_win_size = config.getint('info_win_height', section='var') - self.tab_win = windows.GlobalInfoBar(self) # Whether the XML tab is opened self.xml_tab = None self.xml_buffer = TextBuffer() @@ -108,14 +170,13 @@ class Core: self.events = events.EventHandler() self.events.add_event_handler('tab_change', self.on_tab_change) - self.tabs = Tabs(self.events) + self.tabs = Tabs(self.events, GapTab()) 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.own_nick: str = ( + config.getstr('default_nick') or self.xmpp.boundjid.user or + os.environ.get('USER') or 'poezio_user' + ) self.size = SizeManager(self) @@ -202,6 +263,7 @@ class Core: '_show_plugins': self.command.plugins, '_show_xmltab': self.command.xml_tab, '_toggle_pane': self.toggle_left_pane, + "_go_to_room_name": self.go_to_room_name, ###### status actions ###### '_available': lambda: self.command.status('available'), '_away': lambda: self.command.status('away'), @@ -209,12 +271,12 @@ class Core: '_dnd': lambda: self.command.status('dnd'), '_xa': lambda: self.command.status('xa'), ##### Custom actions ######## - '_exc_': self.try_execute, } self.key_func.update(key_func) + self.key_func.try_execute = self.try_execute # Add handlers - xmpp_event_handlers = [ + xmpp_event_handlers: List[Tuple[str, Callable[..., Any]]] = [ ('attention', self.handler.on_attention), ('carbon_received', self.handler.on_carbon_received), ('carbon_sent', self.handler.on_carbon_sent), @@ -227,6 +289,7 @@ class Core: ('connected', self.handler.on_connected), ('connection_failed', self.handler.on_failed_connection), ('disconnected', self.handler.on_disconnected), + ('reconnect_delay', self.handler.on_reconnect_delay), ('failed_all_auth', self.handler.on_failed_all_auth), ('got_offline', self.handler.on_got_offline), ('got_online', self.handler.on_got_online), @@ -240,6 +303,7 @@ class Core: ('groupchat_subject', self.handler.on_groupchat_subject), ('http_confirm', self.handler.http_confirm), ('message', self.handler.on_message), + ('message_encryption', self.handler.on_encrypted_message), ('message_error', self.handler.on_error_message), ('message_xform', self.handler.on_data_form), ('no_auth', self.handler.on_no_auth), @@ -256,6 +320,9 @@ class Core: ('roster_update', self.handler.on_roster_update), ('session_start', self.handler.on_session_start), ('session_start', self.handler.on_session_start_features), + ('session_end', self.handler.on_session_end), + ('sm_failed', self.handler.on_session_end), + ('session_resumed', self.handler.on_session_resumed), ('ssl_cert', self.handler.validate_ssl), ('ssl_invalid_chain', self.handler.ssl_invalid_chain), ('stream_error', self.handler.on_stream_error), @@ -263,35 +330,20 @@ class Core: for name, handler in xmpp_event_handlers: self.xmpp.add_event_handler(name, handler) - if config.get('enable_avatars'): + if config.getbool('enable_avatars'): self.xmpp.add_event_handler("vcard_avatar_update", self.handler.on_vcard_avatar) self.xmpp.add_event_handler("avatar_metadata_publish", self.handler.on_0084_avatar) - if config.get('enable_user_tune'): - self.xmpp.add_event_handler("user_tune_publish", - self.handler.on_tune_event) - if config.get('enable_user_nick'): + if config.getbool('enable_user_nick'): self.xmpp.add_event_handler("user_nick_publish", self.handler.on_nick_received) - if config.get('enable_user_mood'): - self.xmpp.add_event_handler("user_mood_publish", - self.handler.on_mood_event) - if config.get('enable_user_activity'): - self.xmpp.add_event_handler("user_activity_publish", - self.handler.on_activity_event) - if config.get('enable_user_gaming'): - self.xmpp.add_event_handler("user_gaming_publish", - self.handler.on_gaming_event) - all_stanzas = Callback('custom matcher', connection.MatchAll(None), self.handler.incoming_stanza) self.xmpp.register_handler(all_stanzas) self.initial_joins = [] - self.connected_events = {} - self.pending_invites = {} # a dict of the form {'config_option': [list, of, callbacks]} @@ -307,13 +359,12 @@ class Core: # The callback takes two argument: the config option, and the new # value self.configuration_change_handlers = defaultdict(list) - config_handlers = [ + config_handlers: List[Tuple[str, Callable[..., Any]]] = [ ('', self.on_any_config_change), ('ack_message_receipts', self.on_ack_receipts_config_change), ('connection_check_interval', self.xmpp.set_keepalive_values), ('connection_timeout_delay', self.xmpp.set_keepalive_values), ('create_gaps', self.on_gaps_config_change), - ('deterministic_nick_colors', self.on_nick_determinism_changed), ('enable_carbons', self.on_carbons_switch), ('enable_vertical_tab_list', self.on_vertical_tab_list_config_change), @@ -324,6 +375,7 @@ class Core: ('plugins_dir', self.plugin_manager.on_plugins_dir_change), ('request_message_receipts', self.on_request_receipts_config_change), + ('show_timestamps', self.on_show_timestamps_changed), ('theme', self.on_theme_config_change), ('themes_dir', theming.update_themes_dir), ('use_bookmarks_method', self.on_bookmarks_method_config_change), @@ -333,7 +385,14 @@ class Core: for option, handler in config_handlers: self.add_configuration_handler(option, handler) - def on_tab_change(self, old_tab: tabs.Tab, new_tab: tabs.Tab): + def _create_windows(self): + """Create the windows (delayed after curses init)""" + self.information_win = windows.TextWin(300) + self.information_buffer.add_window(self.information_win) + self.left_tab_win = None + self.tab_win = windows.GlobalInfoBar(self) + + def on_tab_change(self, old_tab: Tab, new_tab: Tab): """Whenever the current tab changes, change focus and refresh""" old_tab.on_lose_focus() new_tab.on_gain_focus() @@ -374,6 +433,12 @@ class Core: """ self.call_for_resize() + def on_show_timestamps_changed(self, option, value): + """ + Called when the show_timestamps option changes + """ + self.call_for_resize(ui_config_changed=True) + def on_bookmarks_method_config_change(self, option, value): """ Called when the use_bookmarks_method option changes @@ -381,7 +446,9 @@ class Core: if value not in ('pep', 'privatexml'): return self.bookmarks.preferred = value - self.bookmarks.save(self.xmpp, core=self) + asyncio.create_task( + self.bookmarks.save(self.xmpp, core=self) + ) def on_gaps_config_change(self, option, value): """ @@ -425,14 +492,6 @@ class Core: """ 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 @@ -496,12 +555,6 @@ class Core: } 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) @@ -510,13 +563,13 @@ class Core: """ Load the plugins on startup. """ - plugins = config.get('plugins_autoload') + plugins = config.getstr('plugins_autoload') if ':' in plugins: for plugin in plugins.split(':'): - self.plugin_manager.load(plugin) + self.plugin_manager.load(plugin, unload_first=False) else: for plugin in plugins.split(): - self.plugin_manager.load(plugin) + self.plugin_manager.load(plugin, unload_first=False) self.plugins_autoloaded = True def start(self): @@ -525,12 +578,20 @@ class Core: """ self.stdscr = curses.initscr() self._init_curses(self.stdscr) + windows.base_wins.TAB_WIN = self.stdscr + self._create_windows() self.call_for_resize() - default_tab = tabs.RosterInfoTab(self) + default_tab = RosterInfoTab(self) default_tab.on_gain_focus() self.tabs.append(default_tab) self.information('Welcome to poezio!', 'Info') - if firstrun: + if curses.COLORS < 256: + self.information( + 'Your terminal does not appear to support 256 colors, the UI' + ' colors will probably be ugly', + 'Error', + ) + if self.firstrun: self.information( 'It seems that it is the first time you start poezio.\n' 'The online help is here https://doc.poez.io/\n\n' @@ -558,7 +619,7 @@ class Core: pass sys.__excepthook__(typ, value, trace) - def sigwinch_handler(self): + def sigwinch_handler(self, *args): """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 @@ -600,7 +661,7 @@ class Core: except ValueError: pass else: - if self.tabs.current_tab.nb == nb and config.get( + if self.tabs.current_tab.nb == nb and config.getbool( 'go_to_previous_tab_on_alt_number'): self.go_to_previous_tab() else: @@ -613,10 +674,28 @@ class Core: 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 loop_exception_handler(self, loop, context) -> None: + """Do not log unhandled iq errors and timeouts""" + handled_exceptions = (IqError, IqTimeout, InvalidCABundle) + if not isinstance(context['exception'], handled_exceptions): + loop.default_exception_handler(context) + elif isinstance(context['exception'], InvalidCABundle): + paths = context['exception'].path + error = ( + 'Poezio could not find a valid CA bundle file automatically. ' + 'Ensure the ca_cert_path configuration is set to a valid ' + 'CA bundle path, generally provided by the \'ca-certificates\' ' + 'package in your distribution.' + ) + if isinstance(paths, (str, Path)): + # error += '\nFound the following value: {path}'.format(path=str(path)) + paths = [paths] + if paths is not None: + error += f"\nThe following values were tried: {str([str(s) for s in paths])}" + self.information(error, 'Error') + def save_config(self): """ Save config in the file just before exit @@ -635,13 +714,13 @@ class Core: """ 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) + self.open_conversation_window(JID(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) + self.open_conversation_window(JID(roster_row.jid)) else: self.focus_tab_named(roster_row.jid) self.refresh_window() @@ -654,7 +733,7 @@ class Core: Messages are namedtuples of the form ('txt nick_color time str_time nickname user') """ - if not isinstance(self.tabs.current_tab, tabs.ChatTab): + if not isinstance(self.tabs.current_tab, ChatTab): return None return self.tabs.current_tab.get_conversation_messages() @@ -711,9 +790,9 @@ class Core: 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'): + if config.getbool('exec_remote'): # We just write the command in the fifo - fifo_path = config.get('remote_fifo_path') + fifo_path = config.getstr('remote_fifo_path') filename = os.path.join(fifo_path, 'poezio.fifo') if not self.remote_fifo: try: @@ -785,16 +864,18 @@ class Core: def remove_timed_event(self, event: DelayedEvent) -> None: """Remove an existing timed event""" - event.handler.cancel() + if event.handler is not None: + event.handler.cancel() def add_timed_event(self, event: DelayedEvent) -> None: """Add a new timed event""" event.handler = asyncio.get_event_loop().call_later( - event.delay, event.callback, *event.args) + event.delay, event.callback, *event.args + ) ####################### XMPP-related actions ################################## - def get_status(self) -> str: + def get_status(self) -> Status: """ Get the last status that was previously set """ @@ -807,7 +888,7 @@ class Core: or to use it when joining a new muc) """ self.status = Status(show=pres, message=msg) - if config.get('save_status'): + if config.getbool('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) @@ -822,7 +903,7 @@ class Core: or the default nickname """ bm = self.bookmarks[room_name] - if bm: + if bm and bm.nick: return bm.nick return self.own_nick @@ -832,16 +913,12 @@ class Core: parts of the client (for example, set the MucTabs as not joined, etc) """ self.legitimate_disconnect = True - 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) + self.xmpp.reconnect(wait=0.0, reason=msg) + else: + for tab in self.get_tabs(MucTab): + tab.leave_room(msg) + self.xmpp.disconnect(reason=msg) def send_message(self, msg: str) -> bool: """ @@ -849,32 +926,48 @@ class Core: conversation. Returns False if the current tab is not a conversation tab """ - if not isinstance(self.tabs.current_tab, tabs.ChatTab): + if not isinstance(self.tabs.current_tab, ChatTab): return False - self.tabs.current_tab.command_say(msg) + asyncio.ensure_future( + self.tabs.current_tab.command_say(msg) + ) return True - def invite(self, jid: JID, room: JID, reason: Optional[str] = None) -> None: + async def invite(self, jid: JID, room: JID, reason: Optional[str] = None, force_mediated: bool = False) -> bool: """ Checks if the sender supports XEP-0249, then send an invitation, or a mediated one if it does not. TODO: allow passwords """ + features = set() - 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) + # force mediated: act as if the other entity does not + # support direct invites + if not force_mediated: + try: + iq = await self.xmpp.plugin['xep_0030'].get_info( + jid=jid, + timeout=5, + ) + features = iq['disco_info'].get_features() + except (IqError, IqTimeout): + pass + supports_direct = 'jabber:x:conference' in features + if supports_direct: + self.xmpp.plugin['xep_0249'].send_invitation( + jid=jid, + roomjid=room, + reason=reason + ) + else: # fallback + self.xmpp.plugin['xep_0045'].invite( + jid=jid, + room=room, + reason=reason or '', + ) + return True - def _impromptu_room_form(self, room): + def _impromptu_room_form(self, room) -> Iq: fields = [ ('hidden', 'FORM_TYPE', 'http://jabber.org/protocol/muc#roomconfig'), ('boolean', 'muc#roomconfig_changesubject', True), @@ -935,74 +1028,78 @@ class Core: ) return - nick = self.own_nick - localpart = uuid.uuid4().hex - room = '{!s}@{!s}'.format(localpart, default_muc) + # Retries generating a name until we find a non-existing room. + # Abort otherwise. + retries = 3 + while retries > 0: + localpart = utils.pronounceable() + room_str = f'{localpart}@{default_muc}' + try: + room = JID(room_str) + except InvalidJID: + self.information( + f'The generated XMPP address is invalid: {room_str}', + 'Error' + ) + return None - self.open_new_room(room, nick).join() - iq = self._impromptu_room_form(room) - try: - await iq.send() - except (IqError, IqTimeout): - self.information('Failed to configure impromptu room.', 'Info') - # TODO: destroy? leave room. + try: + iq = await self.xmpp['xep_0030'].get_info( + jid=room, + cached=False, + ) + except IqTimeout: + pass + except IqError as exn: + if exn.etype == 'cancel' and exn.condition == 'item-not-found': + log.debug('Found empty room for /impromptu') + break + + retries = retries - 1 + + if retries == 0: + self.information( + 'Couldn\'t generate a room name that isn\'t already used.', + 'Error', + ) return None - self.information('Room %s created' % room, 'Info') + self.open_new_room(room, self.own_nick).join() - for jid in jids: - self.invite(jid, room) + async def configure_and_invite(_presence): + iq = self._impromptu_room_form(room) + try: + await iq.send() + except (IqError, IqTimeout): + self.information('Failed to configure impromptu room.', 'Info') + # TODO: destroy? leave room. + return None - def get_error_message(self, stanza, deprecated: bool = False): - """ - Takes a stanza of the form <message type='error'><error/></message> - and return a well formed string containing error information - """ - sender = stanza['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 + self.information(f'Room {room} created', 'Info') + + for jid in jids: + await self.invite(jid, room, force_mediated=True) + jids_str = ', '.join(jids) + self.information(f'Invited {jids_str} to {room.bare}', 'Info') + + self.xmpp.add_event_handler( + f'muc::{room.bare}::groupchat_subject', + configure_and_invite, + disposable=True, + ) ####################### Tab logic-related things ############################## ### Tab getters ### - def get_tabs(self, cls: Type[tabs.Tab] = None) -> List[tabs.Tab]: + def get_tabs(self, cls: Type[T]) -> List[T]: "Get all the tabs of a type" - if cls is None: - return self.tabs.get_tabs() return self.tabs.by_class(cls) def get_conversation_by_jid(self, jid: JID, create: bool = True, - fallback_barejid: bool = True) -> Optional[tabs.ChatTab]: + fallback_barejid: bool = True) -> Optional[ChatTab]: """ From a JID, get the tab containing the conversation with it. If none already exist, and create is "True", we create it @@ -1011,31 +1108,32 @@ class Core: 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) + jid = JID(jid) # We first check if we have a static conversation opened # with this precise resource + conversation: Optional[ConversationTab] conversation = self.tabs.by_name_and_class(jid.full, - tabs.StaticConversationTab) + StaticConversationTab) if jid.bare == jid.full and not conversation: conversation = self.tabs.by_name_and_class( - jid.full, tabs.DynamicConversationTab) + jid.full, DynamicConversationTab) if not conversation and fallback_barejid: # If not, we search for a conversation with the bare jid conversation = self.tabs.by_name_and_class( - jid.bare, tabs.DynamicConversationTab) + jid.bare, 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) + JID(jid.bare), False) else: conversation = None return conversation - def add_tab(self, new_tab: tabs.Tab, focus: bool = False) -> None: + def add_tab(self, new_tab: Tab, focus: bool = False) -> None: """ Appends the new_tab in the tab list and focus it if focus==True @@ -1050,21 +1148,21 @@ class Core: returns False if it could not move the tab, True otherwise """ return self.tabs.insert_tab(old_pos, new_pos, - config.get('create_gaps')) + config.getbool('create_gaps')) ### Move actions (e.g. go to next room) ### - def rotate_rooms_right(self, args=None) -> None: + def rotate_rooms_right(self) -> None: """ rotate the rooms list to the right """ - self.tabs.next() + self.tabs.next() # pylint: disable=not-callable - def rotate_rooms_left(self, args=None) -> None: + def rotate_rooms_left(self) -> None: """ rotate the rooms list to the right """ - self.tabs.prev() + self.tabs.prev() # pylint: disable=not-callable def go_to_room_number(self) -> None: """ @@ -1092,6 +1190,34 @@ class Core: keyboard.continuation_keys_callback = read_next_digit + def go_to_room_name(self) -> None: + room_name_jump = [] + + def read_next_letter(s) -> None: + nonlocal room_name_jump + room_name_jump.append(s) + any_matched, unique_tab = self.tabs.find_by_unique_prefix( + "".join(room_name_jump) + ) + + if not any_matched: + return + + if unique_tab is not None: + self.tabs.set_current_tab(unique_tab) + # NOTE: returning here means that as soon as the tab is + # matched, normal input resumes. If we do *not* return here, + # any further characters matching the prefix of the tab will + # be swallowed (and a lot of tab switching will happen...), + # until a non-matching character or escape or something is + # pressed. + # This behaviour *may* be desirable. + return + + keyboard.continuation_keys_callback = read_next_letter + + keyboard.continuation_keys_callback = read_next_letter + def go_to_roster(self) -> None: "Select the roster as the current tab" self.tabs.set_current_tab(self.tabs.first()) @@ -1103,11 +1229,11 @@ class Core: def go_to_important_room(self) -> None: """ Go to the next room with activity, in the order defined in the - dict tabs.STATE_PRIORITY + dict STATE_PRIORITY """ # shortcut - priority = tabs.STATE_PRIORITY - tab_refs = {} # type: Dict[str, List[tabs.Tab]] + priority = STATE_PRIORITY + tab_refs: Dict[str, List[Tab]] = {} # put all the active tabs in a dict of lists by state for tab in self.tabs.get_tabs(): if not tab: @@ -1132,7 +1258,7 @@ class Core: def focus_tab_named(self, tab_name: str, - type_: Type[tabs.Tab] = None) -> bool: + type_: Type[Tab] = None) -> bool: """Returns True if it found a tab to focus on""" if type_ is None: tab = self.tabs.by_name(tab_name) @@ -1143,23 +1269,24 @@ class Core: return True return False - def focus_tab(self, tab: tabs.Tab) -> bool: + def focus_tab(self, tab: Tab) -> bool: """Focus a tab""" return self.tabs.set_current_tab(tab) ### Opening actions ### def open_conversation_window(self, jid: JID, - focus=True) -> tabs.ConversationTab: + focus=True) -> ConversationTab: """ 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(self, jid) + new_tab: ConversationTab + if jid.resource: + new_tab = StaticConversationTab(self, jid) else: - new_tab = tabs.DynamicConversationTab(self, jid) + new_tab = DynamicConversationTab(self, jid) if not focus: new_tab.state = "private" self.add_tab(new_tab, focus) @@ -1167,41 +1294,41 @@ class Core: return new_tab def open_private_window(self, room_name: str, user_nick: str, - focus=True) -> Optional[tabs.PrivateTab]: + focus=True) -> Optional[PrivateTab]: """ 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): + for tab in self.get_tabs(PrivateTab): if tab.name == complete_jid: self.tabs.set_current_tab(tab) return tab # create the new tab - tab = self.tabs.by_name_and_class(room_name, tabs.MucTab) - if not tab: + muc_tab = self.tabs.by_name_and_class(room_name, MucTab) + if not muc_tab: return None - new_tab = tabs.PrivateTab(self, complete_jid, tab.own_nick) + tab = PrivateTab(self, complete_jid, muc_tab.own_nick) if hasattr(tab, 'directed_presence'): - new_tab.directed_presence = tab.directed_presence + tab.directed_presence = tab.directed_presence if not focus: - new_tab.state = "private" + tab.state = "private" # insert it in the tabs - self.add_tab(new_tab, focus) + self.add_tab(tab, focus) self.refresh_window() - tab.privates.append(new_tab) - return new_tab + muc_tab.privates.append(tab) + return tab def open_new_room(self, - room: str, + room: JID, nick: str, *, password: Optional[str] = None, - focus=True) -> tabs.MucTab: + focus=True) -> MucTab: """ Open a new tab.MucTab containing a muc Room, using the specified nick """ - new_tab = tabs.MucTab(self, room, nick, password=password) + new_tab = MucTab(self, room, nick, password=password) self.add_tab(new_tab, focus) self.refresh_window() return new_tab @@ -1213,19 +1340,19 @@ class Core: The callback are called with the completed form as parameter in addition with kwargs """ - form_tab = tabs.DataFormsTab(self, form, on_cancel, on_send, kwargs) + form_tab = DataFormsTab(self, form, on_cancel, on_send, kwargs) self.add_tab(form_tab, True) ### Modifying actions ### def rename_private_tabs(self, room_name: str, old_nick: str, user: User) -> None: """ - Call this method when someone changes his/her nick in a MUC, + Call this method when someone changes their nick in a MUC, this updates the name of all the opened private conversations with him/her """ tab = self.tabs.by_name_and_class('%s/%s' % (room_name, old_nick), - tabs.PrivateTab) + PrivateTab) if tab: tab.rename_user(old_nick, user) @@ -1236,7 +1363,7 @@ class Core: private conversation """ tab = self.tabs.by_name_and_class('%s/%s' % (room_name, user.nick), - tabs.PrivateTab) + PrivateTab) if tab: tab.user_left(status_message, user) @@ -1246,7 +1373,7 @@ class Core: private conversation """ tab = self.tabs.by_name_and_class('%s/%s' % (room_name, nick), - tabs.PrivateTab) + PrivateTab) if tab: tab.user_rejoined(nick) @@ -1258,7 +1385,7 @@ class Core: """ if reason is None: reason = '\x195}You left the room\x193}' - for tab in self.get_tabs(tabs.PrivateTab): + for tab in self.get_tabs(PrivateTab): if tab.name.startswith(room_name): tab.deactivate(reason=reason) @@ -1269,28 +1396,28 @@ class Core: """ if reason is None: reason = '\x195}You joined the room\x193}' - for tab in self.get_tabs(tabs.PrivateTab): + for tab in self.get_tabs(PrivateTab): if tab.name.startswith(room_name): tab.activate(reason=reason) - def on_user_changed_status_in_private(self, jid: JID, status: str) -> None: - tab = self.tabs.by_name_and_class(jid, tabs.ChatTab) + def on_user_changed_status_in_private(self, jid: JID, status: Status) -> None: + tab = self.tabs.by_name_and_class(jid, OneToOneTab) if tab is not None: # display the message in private tab.update_status(status) - def close_tab(self, to_close: tabs.Tab = None) -> None: + def close_tab(self, to_close: Tab = None) -> None: """ Close the given tab. If None, close the current one """ was_current = to_close is None tab = to_close or self.tabs.current_tab - if isinstance(tab, tabs.RosterInfoTab): + if isinstance(tab, RosterInfoTab): return # The tab 0 should NEVER be closed tab.on_close() del tab.key_func # Remove self references del tab.commands # and make the object collectable - self.tabs.delete(tab, gap=config.get('create_gaps')) + self.tabs.delete(tab, gap=config.getbool('create_gaps')) logger.close(tab.name) if was_current: self.tabs.current_tab.on_gain_focus() @@ -1306,9 +1433,9 @@ class Core: Search for a ConversationTab with the given jid (full or bare), if yes, add the given message to it """ - tab = self.tabs.by_name_and_class(jid, tabs.ConversationTab) + tab = self.tabs.by_name_and_class(jid, ConversationTab) if tab is not None: - tab.add_message(msg, typ=2) + tab.add_message(PersistentInfoMessage(msg)) if self.tabs.current_tab is tab: self.refresh_window() @@ -1316,36 +1443,36 @@ class Core: def doupdate(self) -> None: "Do a curses update" - if not self.running: - return curses.doupdate() def information(self, msg: str, typ: str = '') -> bool: """ Displays an informational message in the "Info" buffer """ - filter_types = config.get('information_buffer_type_filter').split(':') + filter_types = config.getlist('information_buffer_type_filter') if typ.lower() in filter_types: log.debug( 'Did not show the message:\n\t%s> %s \n\tdue to ' 'information_buffer_type_filter configuration', typ, msg) return False - filter_messages = config.get('filter_info_messages').split(':') + filter_messages = config.getlist('filter_info_messages') for words in filter_messages: if words and words in msg: log.debug( 'Did not show the message:\n\t%s> %s \n\tdue to filter_info_messages configuration', 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.tabs.current_tab, tabs.RosterInfoTab): + UIMessage( + txt=msg, + level=typ, + ) + ) + popup_on = config.getlist('information_buffer_popup_on') + if isinstance(self.tabs.current_tab, RosterInfoTab): self.refresh_window() elif typ != '' and typ.lower() in popup_on: - popup_time = config.get('popup_time') + (nb_lines - 1) * 2 + popup_time = config.getint('popup_time') + (nb_lines - 1) * 2 self._pop_information_win_up(nb_lines, popup_time) else: if self.information_win_size != 0: @@ -1493,7 +1620,7 @@ class Core: Scroll the information buffer up """ self.information_win.scroll_up(self.information_win.height) - if not isinstance(self.tabs.current_tab, tabs.RosterInfoTab): + if not isinstance(self.tabs.current_tab, RosterInfoTab): self.information_win.refresh() else: info = self.tabs.current_tab.information_win @@ -1505,7 +1632,7 @@ class Core: Scroll the information buffer down """ self.information_win.scroll_down(self.information_win.height) - if not isinstance(self.tabs.current_tab, tabs.RosterInfoTab): + if not isinstance(self.tabs.current_tab, RosterInfoTab): self.information_win.refresh() else: info = self.tabs.current_tab.information_win @@ -1530,57 +1657,47 @@ class Core: """ Enable/disable the left panel. """ - enabled = config.get('enable_vertical_tab_list') + enabled = config.getbool('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): + def resize_global_information_win(self, ui_config_changed: bool = False): """ 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: + if self.information_win_size > Tab.height - 6: + self.information_win_size = Tab.height - 6 + if 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) + height = (Tab.height - 1 - self.information_win_size - + Tab.tab_win_height()) + self.information_win.resize(self.information_win_size, Tab.width, + height, 0, self.information_buffer, + force=ui_config_changed) 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 config.getbool('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) + height, config.getint('vertical_tab_list_size'), 0, 0) except: log.error('Curses error on infobar resize', exc_info=True) return self.left_tab_win = windows.VerticalGlobalInfoBar( self, truncated_win) elif not self.size.core_degrade_y: - self.tab_win.resize(1, tabs.Tab.width, tabs.Tab.height - 2, 0) + self.tab_win.resize(1, Tab.width, Tab.height - 2, 0) self.left_tab_win = None - def add_message_to_text_buffer(self, buff, txt, nickname=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') - return - buff.add_message(txt, nickname=nickname) - def full_screen_redraw(self): """ Completely erase and redraw the screen @@ -1588,7 +1705,7 @@ class Core: self.stdscr.clear() self.refresh_window() - def call_for_resize(self): + def call_for_resize(self, ui_config_changed: bool = False): """ Called when we want to resize the screen """ @@ -1596,22 +1713,27 @@ class Core: # 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 + if self.stdscr is None: + raise ValueError('No output available') height, width = self.stdscr.getmaxyx() - if (config.get('enable_vertical_tab_list') + if (config.getbool('enable_vertical_tab_list') and not self.size.core_degrade_x): try: - scr = self.stdscr.subwin(0, - config.get('vertical_tab_list_size')) + scr = self.stdscr.subwin( + 0, + config.getint('vertical_tab_list_size') + ) except: log.error('Curses error on resize', exc_info=True) return else: scr = self.stdscr - tabs.Tab.resize(scr) + Tab.initial_resize(scr) self.resize_global_info_bar() - self.resize_global_information_win() + self.resize_global_information_win(ui_config_changed) for tab in self.tabs: - if config.get('lazy_resize'): + tab.ui_config_changed = True + if config.getbool('lazy_resize'): tab.need_resize = True else: tab.resize() @@ -1654,342 +1776,56 @@ class Core: """ 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 rooms" - " 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( - 'impromptu', - self.command.impromptu, - usage='<jid> [jid ...]', - desc='Invite specified JIDs into a newly created room.', - shortdesc='Invite specified JIDs into newly created room.', - completion=self.completion.impromptu) - 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'): + for command in get_commands(self.command, self.completion, self.plugin_manager): + self.register_command(**command) + + def check_blocking(self, features: List[str]): + if 'urn:xmpp:blocking' in features and not self.xmpp.anon: 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'): + 'block', + self.command.block, + usage='[jid]', + shortdesc='Prevent a JID from talking to you.', + completion=self.completion.block) 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) + 'unblock', + self.command.unblock, + usage='[jid]', + shortdesc='Allow a JID to talk to you.', + completion=self.completion.unblock) + self.xmpp.del_event_handler('session_start', self.check_blocking) ####################### Random things to move ################################# - def join_initial_rooms(self, bookmarks): + def join_initial_rooms(self, bookmarks: List[Bookmark]): """Join all rooms given in the iterator `bookmarks`""" for bm in bookmarks: - if not (bm.autojoin or config.get('open_all_bookmarks')): + if not (bm.autojoin or config.getbool('open_all_bookmarks')): continue - tab = self.tabs.by_name_and_class(bm.jid, tabs.MucTab) + tab = self.tabs.by_name_and_class(bm.jid, MucTab) nick = bm.nick if bm.nick else self.own_nick if not tab: - self.open_new_room( + 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): + if bm.autojoin and tab: + tab.join() + + async def check_bookmark_storage(self, features: List[str]): 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 self.xmpp.anon and config.getbool('use_remote_bookmarks'): + try: + await self.bookmarks.get_remote(self.xmpp, self.information) + except IqError as error: + type_ = error.iq['error']['type'] + condition = error.iq['error']['condition'] if not (type_ == 'cancel' and condition == 'item-not-found'): self.information( 'Unable to fetch the remote' @@ -1998,38 +1834,37 @@ class Core: remote_bookmarks = self.bookmarks.remote() self.join_initial_rooms(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 room_error(self, error, room_name): + def room_error(self, error, room_name: str) -> None: """ Display the error in the tab """ - tab = self.tabs.by_name_and_class(room_name, tabs.MucTab) + tab = self.tabs.by_name_and_class(room_name, MucTab) if not tab: return - error_message = self.get_error_message(error) + error_message = get_error_message(error) tab.add_message( - error_message, - highlight=True, - nickname='Error', - nick_color=get_theme().COLOR_ERROR_MSG, - typ=2) + UIMessage( + error_message, + level='Error', + ), + ) 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) + tab.add_message(PersistentInfoMessage(msg)) if code == '409': - if config.get('alternative_nickname') != '': + if config.getstr('alternative_nickname') != '': if not tab.joined: - tab.own_nick += config.get('alternative_nickname') + tab.own_nick += config.getstr('alternative_nickname') tab.join() else: if not tab.joined: tab.add_message( - 'You can join the room with an other nick, by typing "/join /other_nick"', - typ=2) + PersistentInfoMessage( + 'You can join the room with another nick, ' + 'by typing "/join /other_nick"' + ) + ) self.refresh_window() @@ -2038,13 +1873,18 @@ class KeyDict(dict): A dict, with a wrapper for get() that will return a custom value if the key starts with _exc_ """ + try_execute: Optional[Callable[[str], Any]] - def get(self, key: str, default: Optional[Callable] = None) -> Callable: + def get(self, key: str, default=None) -> Callable: if isinstance(key, str) and key.startswith('_exc_') and len(key) > 5: - return lambda: dict.get(self, '_exc_')(key[5:]) + if self.try_execute is not None: + try_execute = self.try_execute + return lambda: try_execute(key[5:]) + raise ValueError("KeyDict not initialized") return dict.get(self, key, default) + def replace_key_with_bound(key: str) -> str: """ Replace an inputted key with the one defined as its replacement diff --git a/poezio/core/handlers.py b/poezio/core/handlers.py index 0a6e7e50..e92e4aac 100644 --- a/poezio/core/handlers.py +++ b/poezio/core/handlers.py @@ -3,40 +3,41 @@ XMPP-related handlers for the Core class """ import logging -log = logging.getLogger(__name__) + +from typing import Optional import asyncio import curses -import functools import select +import signal import ssl import sys import time -from datetime import datetime from hashlib import sha1, sha256, sha512 -from os import path import pyasn1.codec.der.decoder import pyasn1.codec.der.encoder import pyasn1_modules.rfc2459 -from slixmpp import InvalidJID +from slixmpp import InvalidJID, JID, Message, Iq, Presence from slixmpp.xmlstream.stanzabase import StanzaBase, ElementBase from xml.etree import ElementTree as ET -from poezio import common -from poezio import fixes -from poezio import pep from poezio import tabs from poezio import xhtml from poezio import multiuserchat as muc -from poezio.common import safeJID +from poezio.common import get_error_message from poezio.config import config, get_image_cache from poezio.core.structs import Status from poezio.contact import Resource from poezio.logger import logger from poezio.roster import roster -from poezio.text_buffer import CorrectionError, AckError +from poezio.text_buffer import AckError from poezio.theming import dump_tuple, get_theme +from poezio.ui.types import ( + XMLLog, + InfoMessage, + PersistentInfoMessage, +) from poezio.core.commands import dumb_callback @@ -50,6 +51,8 @@ try: except ImportError: PYGMENTS = False +log = logging.getLogger(__name__) + CERT_WARNING_TEXT = """ WARNING: CERTIFICATE FOR %s CHANGED @@ -76,101 +79,135 @@ class HandlerCore: def __init__(self, core): self.core = core - def on_session_start_features(self, _): + async 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.core.tabs.by_name_and_class( - 'Roster', tabs.RosterInfoTab) - rostertab.check_blocking(features) - rostertab.check_saslexternal(features) - if (config.get('enable_carbons') - and 'urn:xmpp:carbons:2' in features): - self.core.xmpp.plugin['xep_0280'].enable() - self.core.check_bookmark_storage(features) - - self.core.xmpp.plugin['xep_0030'].get_info( - jid=self.core.xmpp.boundjid.domain, callback=callback) + iq = await self.core.xmpp.plugin['xep_0030'].get_info( + jid=self.core.xmpp.boundjid.domain + ) + features = iq['disco_info']['features'] + + rostertab = self.core.tabs.by_name_and_class( + 'Roster', tabs.RosterInfoTab) + rostertab.check_saslexternal(features) + rostertab.check_blocking(features) + self.core.check_blocking(features) + if (config.getbool('enable_carbons') + and 'urn:xmpp:carbons:2' in features): + self.core.xmpp.plugin['xep_0280'].enable() + await self.core.check_bookmark_storage(features) def find_identities(self, _): - asyncio.ensure_future( + asyncio.create_task( self.core.xmpp['xep_0030'].get_info_from_domain(), ) - def on_carbon_received(self, message): + def is_known_muc_pm(self, message: Message, with_jid: JID) -> Optional[bool]: """ - Carbon <received/> received + Try to determine whether a given message is a MUC-PM, without a roundtrip. Returns None when it's not clear """ - def ignore_message(recv): - log.debug('%s has category conference, ignoring carbon', - recv['from'].server) + # first, look for the x (XEP-0045 version 1.28) + if message.match('message/muc'): + log.debug('MUC-PM from %s with <x>', with_jid) + return True - def receive_message(recv): - recv['to'] = self.core.xmpp.boundjid.full - if recv['receipt']: - return self.on_receipt(recv) - self.on_normal_message(recv) + jid_bare = with_jid.bare + + # then, look whether we have a matching tab with barejid + tab = self.core.tabs.by_jid(JID(jid_bare)) + if tab is not None: + if isinstance(tab, tabs.MucTab): + log.debug('MUC-PM from %s in known MucTab', with_jid) + return True + one_to_one = isinstance(tab, ( + tabs.ConversationTab, + tabs.DynamicConversationTab, + )) + if one_to_one: + return False + + # then, look whether we have a matching tab with fulljid + if with_jid.resource: + tab = self.core.tabs.by_jid(with_jid) + if tab is not None: + if isinstance(tab, tabs.PrivateTab): + log.debug('MUC-PM from %s in known PrivateTab', with_jid) + return True + if isinstance(tab, tabs.StaticConversationTab): + return False + + # then, look in the roster + if jid_bare in roster and roster[jid_bare].subscription != 'none': + return False + + # then, check bookmarks + for bm in self.core.bookmarks: + if bm.jid.bare == jid_bare: + log.debug('MUC-PM from %s in bookmarks', with_jid) + return True + return None + + async def on_carbon_received(self, message: Message): + """ + Carbon <received/> received + """ recv = message['carbon_received'] - if (recv['from'].bare not in roster - or roster[recv['from'].bare].subscription == 'none'): - fixes.has_identity( - self.core.xmpp, - recv['from'].server, - identity='conference', - on_true=functools.partial(ignore_message, recv), - on_false=functools.partial(receive_message, recv)) - return + is_muc_pm = self.is_known_muc_pm(recv, recv['from']) + if is_muc_pm: + log.debug('%s sent a MUC-PM, ignoring carbon', recv['from']) + elif is_muc_pm is None: + is_muc = await self.core.xmpp.plugin['xep_0030'].has_identity( + recv['from'].bare, + node='conference', + ) + if is_muc: + log.debug('%s has category conference, ignoring carbon', + recv['from'].server) + else: + recv['to'] = self.core.xmpp.boundjid.full + if recv['receipt']: + await self.on_receipt(recv) + else: + await self.on_normal_message(recv) else: - receive_message(recv) + recv['to'] = self.core.xmpp.boundjid.full + await self.on_normal_message(recv) - def on_carbon_sent(self, message): + async def on_carbon_sent(self, message: Message): """ Carbon <sent/> received """ - - def groupchat_private_message(sent): - self.on_groupchat_private_message(sent, sent=True) - - def send_message(sent): - sent['from'] = self.core.xmpp.boundjid.full - self.on_normal_message(sent) - sent = message['carbon_sent'] - # todo: implement proper MUC detection logic - if (sent['to'].resource - and (sent['to'].bare not in roster - or roster[sent['to'].bare].subscription == 'none')): - fixes.has_identity( - self.core.xmpp, - sent['to'].server, - identity='conference', - on_true=functools.partial(groupchat_private_message, sent), - on_false=functools.partial(send_message, sent)) + is_muc_pm = self.is_known_muc_pm(sent, sent['to']) + if is_muc_pm: + await self.on_groupchat_private_message(sent, sent=True) + elif is_muc_pm is None: + is_muc = await self.core.xmpp.plugin['xep_0030'].has_identity( + sent['to'].bare, + node='conference', + ) + if is_muc: + await self.on_groupchat_private_message(sent, sent=True) + else: + sent['from'] = self.core.xmpp.boundjid.full + await self.on_normal_message(sent) else: - send_message(sent) + sent['from'] = self.core.xmpp.boundjid.full + await self.on_normal_message(sent) ### Invites ### - def on_groupchat_invitation(self, message): + async def on_groupchat_invitation(self, message: Message): """ Mediated invitation received """ jid = message['from'] if jid.bare in self.core.pending_invites: return - # there are 2 'x' tags in the messages, making message['x'] useless - invite = StanzaBase( - self.core.xmpp, - xml=message.xml.find( - '{http://jabber.org/protocol/muc#user}x/{http://jabber.org/protocol/muc#user}invite' - )) + invite = message['muc']['invite'] # TODO: find out why pylint thinks "inviter" is a list #pylint: disable=no-member inviter = invite['from'] @@ -182,20 +219,23 @@ class HandlerCore: if password: msg += ". The password is \"%s\"." % password self.core.information(msg, 'Info') - if 'invite' in config.get('beep_on').split(): + if 'invite' in config.getstr('beep_on').split(): curses.beep() logger.log_roster_change(inviter.full, 'invited you to %s' % jid.full) self.core.pending_invites[jid.bare] = inviter.full - def on_groupchat_decline(self, decline): + async def on_groupchat_decline(self, decline): "Mediated invitation declined; skip for now" pass - def on_groupchat_direct_invitation(self, message): + async def on_groupchat_direct_invitation(self, message: Message): """ Direct invitation received """ - room = safeJID(message['groupchat_invite']['jid']) + try: + room = JID(message['groupchat_invite']['jid']) + except InvalidJID: + return if room.bare in self.core.pending_invites: return @@ -213,7 +253,7 @@ class HandlerCore: msg += "\nreason: %s" % reason self.core.information(msg, 'Info') - if 'invite' in config.get('beep_on').split(): + if 'invite' in config.getstr('beep_on').split(): curses.beep() self.core.pending_invites[room.bare] = inviter.full @@ -221,37 +261,40 @@ class HandlerCore: ### "classic" messages ### - def on_message(self, message): + async def on_message(self, message: Message): """ When receiving private message from a muc OR a normal message (from one of our contacts) """ - if message.xml.find( - '{http://jabber.org/protocol/muc#user}x/{http://jabber.org/protocol/muc#user}invite' - ) is not None: + if message.match('message/muc/invite'): return if message['type'] == 'groupchat': return # Differentiate both type of messages, and call the appropriate handler. - jid_from = message['from'] - for tab in self.core.get_tabs(tabs.MucTab): - if tab.name == jid_from.bare: - if jid_from.resource: - self.on_groupchat_private_message(message, sent=False) - return - self.on_normal_message(message) + if self.is_known_muc_pm(message, message['from']): + await self.on_groupchat_private_message(message, sent=False) + else: + await self.on_normal_message(message) - def on_error_message(self, message): + async def on_encrypted_message(self, message: Message): + """ + When receiving an encrypted message + """ + if message["body"]: + return # Already being handled by on_message. + await self.on_message(message) + + async def on_error_message(self, message: Message): """ When receiving any message with type="error" """ jid_from = message['from'] for tab in self.core.get_tabs(tabs.MucTab): - if tab.name == jid_from.bare: + if tab.jid.bare == jid_from.bare: if jid_from.full == jid_from.bare: self.core.room_error(message, jid_from.bare) else: - text = self.core.get_error_message(message) + text = get_error_message(message) p_tab = self.core.tabs.by_name_and_class( jid_from.full, tabs.PrivateTab) if p_tab: @@ -260,17 +303,17 @@ class HandlerCore: self.core.information(text, 'Error') return tab = self.core.get_conversation_by_jid(message['from'], create=False) - error_msg = self.core.get_error_message(message, deprecated=True) + error_msg = get_error_message(message, deprecated=True) if not tab: self.core.information(error_msg, 'Error') return 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) + tab.add_message(InfoMessage(error)) self.core.refresh_window() - def on_normal_message(self, message): + async def on_normal_message(self, message: Message): """ When receiving "normal" messages (not a private message from a muc participant) @@ -284,94 +327,36 @@ class HandlerCore: use_xhtml = config.get_by_tabname('enable_xhtml_im', message['from'].bare) tmp_dir = get_image_cache() - body = xhtml.get_body_from_message_stanza( - message, use_xhtml=use_xhtml, extract_images_to=tmp_dir) - if not body: + if not xhtml.get_body_from_message_stanza( + message, use_xhtml=use_xhtml, extract_images_to=tmp_dir): if not self.core.xmpp.plugin['xep_0380'].has_eme(message): return self.core.xmpp.plugin['xep_0380'].replace_body_with_eme(message) - body = message['body'] - remote_nick = '' # normal message, we are the recipient if message['to'].bare == self.core.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.core.xmpp.boundjid.bare: conv_jid = message['to'] - jid = self.core.xmpp.boundjid - color = get_theme().COLOR_OWN_NICK - remote_nick = self.core.own_nick own = True # we are not part of that message, drop it else: return - conversation = self.core.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: - remote_nick = conversation.get_nick() - - if not own: - conversation.last_remote_message = datetime.now() - - self.core.events.trigger('conversation_msg', message, conversation) - if not message['body']: - return - body = xhtml.get_body_from_message_stanza( - message, use_xhtml=use_xhtml, extract_images_to=tmp_dir) - delayed, date = common.find_delayed_tag(message) - - def try_modify(): - if message.xml.find('{urn:xmpp:message-correct:0}replace') is None: - return False - 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 + conversation = self.core.get_conversation_by_jid(conv_jid, create=False) + if conversation is None: + conversation = tabs.DynamicConversationTab( + self.core, + JID(conv_jid.bare), + initial=message, + ) + self.core.tabs.append(conversation) + else: + await conversation.handle_message(message) - 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 not own and 'private' in config.get('beep_on').split(): + if not own and 'private' in config.getstr('beep_on').split(): if not config.get_by_tabname('disable_beep', conv_jid.bare): curses.beep() if self.core.tabs.current_tab is not conversation: @@ -384,7 +369,7 @@ class HandlerCore: else: self.core.refresh_window() - async def on_0084_avatar(self, msg): + async def on_0084_avatar(self, msg: Message): jid = msg['from'].bare contact = roster[jid] if not contact: @@ -434,7 +419,7 @@ class HandlerCore: exc_info=True) return - async def on_vcard_avatar(self, pres): + async def on_vcard_avatar(self, pres: Presence): jid = pres['from'].bare contact = roster[jid] if not contact: @@ -470,9 +455,9 @@ class HandlerCore: log.debug( 'Failed writing %s’s avatar to cache:', jid, exc_info=True) - def on_nick_received(self, message): + async def on_nick_received(self, message: Message): """ - Called when a pep notification for an user nickname + Called when a pep notification for a user nickname is received """ contact = roster[message['from'].bare] @@ -484,177 +469,10 @@ class HandlerCore: 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') is not None: - 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.core.information( - '%s is playing %s' % (contact.bare_jid, - common.format_gaming_string( - contact.gaming)), 'Gaming') - else: - self.core.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') is not None: - 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.core.information( - 'Mood from ' + contact.bare_jid + ': ' + contact.mood, - 'Mood') - else: - self.core.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') is not None: - 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.core.information( - 'Activity from ' + contact.bare_jid + ': ' + - contact.activity, 'Activity') - else: - self.core.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') is not None: - 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.core.information( - 'Tune from ' + message['from'].bare + ': ' + - common.format_tune_string(contact.tune), 'Tune') - else: - self.core.information( - contact.bare_jid + ' stopped listening to music.', 'Tune') - - def on_groupchat_message(self, message): + async def on_groupchat_message(self, message: Message) -> None: """ 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 @@ -668,88 +486,33 @@ class HandlerCore: muc.leave_groupchat( self.core.xmpp, room_from, self.core.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.core.events.trigger('muc_msg', message, tab) - use_xhtml = config.get_by_tabname('enable_xhtml_im', room_from) - tmp_dir = get_image_cache() - body = xhtml.get_body_from_message_stanza( - message, use_xhtml=use_xhtml, extract_images_to=tmp_dir) - if not body: - return - - old_state = tab.state - delayed, date = common.find_delayed_tag(message) - replaced = False - if message.xml.find('{urn:xmpp:message-correct:0}replace') is not None: - replaced_id = message['replace']['id'] - 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.core.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.core.events.trigger('highlight', message, tab) - - if message['from'].resource == tab.own_nick: - tab.last_sent_message = message - - if tab is self.core.tabs.current_tab: - tab.text_win.refresh() - tab.info_header.refresh(tab, tab.text_win, user=tab.own_user) - tab.input.refresh() - self.core.doupdate() - elif tab.state != old_state: - self.core.refresh_tab_win() - current = self.core.tabs.current_tab - if hasattr(current, 'input') and current.input: - current.input.refresh() - self.core.doupdate() - - if 'message' in config.get('beep_on').split(): + valid_message = await tab.handle_message(message) + if valid_message and 'message' in config.getstr('beep_on').split(): if (not config.get_by_tabname('disable_beep', room_from) and self.core.own_nick != message['from'].resource): curses.beep() - def on_muc_own_nickchange(self, muc): + def on_muc_own_nickchange(self, muc: tabs.MucTab): "We changed our nick in a MUC" for tab in self.core.get_tabs(tabs.PrivateTab): if tab.parent_muc == muc: tab.own_nick = muc.own_nick - def on_groupchat_private_message(self, message, sent): + async def on_groupchat_private_message(self, message: Message, sent: bool): """ We received a Private Message (from someone in a Muc) """ jid = message['to'] if sent else message['from'] with_nick = jid.resource if not with_nick: - self.on_groupchat_message(message) + await self.on_groupchat_message(message) return room_from = jid.bare - use_xhtml = config.get_by_tabname('enable_xhtml_im', jid.bare) + use_xhtml = config.get_by_tabname( + 'enable_xhtml_im', + jid.bare + ) tmp_dir = get_image_cache() body = xhtml.get_body_from_message_stanza( message, use_xhtml=use_xhtml, extract_images_to=tmp_dir) @@ -757,57 +520,27 @@ class HandlerCore: 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.core.open_private_window(room_from, with_nick, - False) - sender_nick = (tab.own_nick - or self.core.own_nick) if sent else with_nick if ignore and not sent: - self.core.events.trigger('ignored_private', message, tab) + await self.core.events.trigger_async('ignored_private', message, tab) msg = config.get_by_tabname('private_auto_response', room_from) if msg and body: self.core.xmpp.send_message( mto=jid.full, mbody=msg, mtype='chat') return - self.core.events.trigger('private_msg', message, tab) - body = xhtml.get_body_from_message_stanza( - message, use_xhtml=use_xhtml, extract_images_to=tmp_dir) - if not body or not tab: - return - replaced = False - user = tab.parent_muc.get_user_by_name(with_nick) - if message.xml.find('{urn:xmpp:message-correct:0}replace') is not None: - replaced_id = message['replace']['id'] - 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=sender_nick) - replaced = True - except CorrectionError: - log.debug('Unable to correct a message', exc_info=True) - if not replaced: - tab.add_message( - body, - time=None, - nickname=sender_nick, - nick_color=get_theme().COLOR_OWN_NICK if sent else None, - forced_user=user, - identifier=message['id'], - jid=message['from'], - typ=1) - if sent: - tab.last_sent_message = msg + if tab is None: # It's the first message we receive: create the tab + if body and not ignore: + tab = tabs.PrivateTab( + self.core, + jid, + self.core.own_nick, + initial=message, + ) + self.core.tabs.append(tab) + tab.parent_muc.privates.append(tab) else: - tab.last_remote_message = datetime.now() + await tab.handle_message(message) - if not sent and 'private' in config.get('beep_on').split(): + if not sent and 'private' in config.getstr('beep_on').split(): if not config.get_by_tabname('disable_beep', jid.full): curses.beep() if tab is self.core.tabs.current_tab: @@ -818,37 +551,37 @@ class HandlerCore: ### Chatstates ### - def on_chatstate_active(self, message): - self._on_chatstate(message, "active") + async def on_chatstate_active(self, message: Message): + await self._on_chatstate(message, "active") - def on_chatstate_inactive(self, message): - self._on_chatstate(message, "inactive") + async def on_chatstate_inactive(self, message: Message): + await self._on_chatstate(message, "inactive") - def on_chatstate_composing(self, message): - self._on_chatstate(message, "composing") + async def on_chatstate_composing(self, message: Message): + await self._on_chatstate(message, "composing") - def on_chatstate_paused(self, message): - self._on_chatstate(message, "paused") + async def on_chatstate_paused(self, message: Message): + await self._on_chatstate(message, "paused") - def on_chatstate_gone(self, message): - self._on_chatstate(message, "gone") + async def on_chatstate_gone(self, message: Message): + await self._on_chatstate(message, "gone") - def _on_chatstate(self, message, state): + async def _on_chatstate(self, message: Message, state: str): if message['type'] == 'chat': - if not self._on_chatstate_normal_conversation(message, state): + if not await self._on_chatstate_normal_conversation(message, state): tab = self.core.tabs.by_name_and_class(message['from'].full, tabs.PrivateTab) if not tab: return - self._on_chatstate_private_conversation(message, state) + await self._on_chatstate_private_conversation(message, state) elif message['type'] == 'groupchat': - self.on_chatstate_groupchat_conversation(message, state) + await self.on_chatstate_groupchat_conversation(message, state) - def _on_chatstate_normal_conversation(self, message, state): + async def _on_chatstate_normal_conversation(self, message: Message, state: str): tab = self.core.get_conversation_by_jid(message['from'], False) if not tab: return False - self.core.events.trigger('normal_chatstate', message, tab) + await self.core.events.trigger_async('normal_chatstate', message, tab) tab.chatstate = state if state == 'gone' and isinstance(tab, tabs.DynamicConversationTab): tab.unlock() @@ -860,7 +593,7 @@ class HandlerCore: self.core.refresh_tab_win() return True - def _on_chatstate_private_conversation(self, message, state): + async def _on_chatstate_private_conversation(self, message: Message, state: str): """ Chatstate received in a private conversation from a MUC """ @@ -868,7 +601,7 @@ class HandlerCore: tabs.PrivateTab) if not tab: return - self.core.events.trigger('private_chatstate', message, tab) + await self.core.events.trigger_async('private_chatstate', message, tab) tab.chatstate = state if tab == self.core.tabs.current_tab: tab.refresh_info_header() @@ -877,7 +610,7 @@ class HandlerCore: _composing_tab_state(tab, state) self.core.refresh_tab_win() - def on_chatstate_groupchat_conversation(self, message, state): + async def on_chatstate_groupchat_conversation(self, message: Message, state: str): """ Chatstate received in a MUC """ @@ -885,7 +618,7 @@ class HandlerCore: room_from = message.get_mucroom() tab = self.core.tabs.by_name_and_class(room_from, tabs.MucTab) if tab and tab.get_user_by_name(nick): - self.core.events.trigger('muc_chatstate', message, tab) + await self.core.events.trigger_async('muc_chatstate', message, tab) tab.get_user_by_name(nick).chatstate = state if tab == self.core.tabs.current_tab: if not self.core.size.tab_degrade_x: @@ -903,7 +636,7 @@ class HandlerCore: return '%s: %s' % (error_condition, error_text) if error_text else error_condition - def on_version_result(self, iq): + def on_version_result(self, iq: Iq): """ Handle the result of a /version command. """ @@ -920,7 +653,7 @@ class HandlerCore: 'an unknown platform')) self.core.information(version, 'Info') - def on_bookmark_result(self, iq): + def on_bookmark_result(self, iq: Iq): """ Handle the result of a /bookmark commands. """ @@ -932,7 +665,7 @@ class HandlerCore: ### subscription-related handlers ### - def on_roster_update(self, iq): + async def on_roster_update(self, iq: Iq): """ The roster was received. """ @@ -951,7 +684,7 @@ class HandlerCore: if isinstance(self.core.tabs.current_tab, tabs.RosterInfoTab): self.core.refresh_window() - def on_subscription_request(self, presence): + async def on_subscription_request(self, presence: Presence): """subscribe received""" jid = presence['from'].bare contact = roster[jid] @@ -974,7 +707,7 @@ class HandlerCore: if isinstance(self.core.tabs.current_tab, tabs.RosterInfoTab): self.core.refresh_window() - def on_subscription_authorized(self, presence): + async def on_subscription_authorized(self, presence: Presence): """subscribed received""" jid = presence['from'].bare contact = roster[jid] @@ -989,7 +722,7 @@ class HandlerCore: if isinstance(self.core.tabs.current_tab, tabs.RosterInfoTab): self.core.refresh_window() - def on_subscription_remove(self, presence): + async def on_subscription_remove(self, presence: Presence): """unsubscribe received""" jid = presence['from'].bare contact = roster[jid] @@ -1002,7 +735,7 @@ class HandlerCore: if isinstance(self.core.tabs.current_tab, tabs.RosterInfoTab): self.core.refresh_window() - def on_subscription_removed(self, presence): + async def on_subscription_removed(self, presence: Presence): """unsubscribed received""" jid = presence['from'].bare contact = roster[jid] @@ -1015,7 +748,7 @@ class HandlerCore: contact.pending_out = False else: self.core.information( - '%s does not want you to receive his/her/its status anymore.' % + '%s does not want you to receive their/its status anymore.' % jid, 'Roster') self.core.tabs.first().state = 'highlight' if isinstance(self.core.tabs.current_tab, tabs.RosterInfoTab): @@ -1023,9 +756,8 @@ class HandlerCore: ### Presence-related handlers ### - def on_presence(self, presence): - if presence.match('presence/muc') or presence.xml.find( - '{http://jabber.org/protocol/muc#user}x') is not None: + async def on_presence(self, presence: Presence): + if presence.match('presence/muc'): return jid = presence['from'] contact = roster[jid.bare] @@ -1039,8 +771,8 @@ class HandlerCore: return roster.modified() contact.error = None - self.core.events.trigger('normal_presence', presence, - contact[jid.full]) + await self.core.events.trigger_async('normal_presence', presence, + contact[jid.full]) tab = self.core.get_conversation_by_jid(jid, create=False) if tab: tab.update_status( @@ -1051,21 +783,20 @@ class HandlerCore: tab.refresh() self.core.doupdate() - def on_presence_error(self, presence): + async def on_presence_error(self, presence: Presence): jid = presence['from'] contact = roster[jid.bare] if not contact: return roster.modified() - contact.error = presence['error']['type'] + ': ' + presence['error']['condition'] + contact.error = presence['error']['text'] or presence['error']['type'] + ': ' + presence['error']['condition'] # TODO: reset chat states status on presence error - def on_got_offline(self, presence): + async def on_got_offline(self, presence: Presence): """ A JID got offline """ - if presence.match('presence/muc') or presence.xml.find( - '{http://jabber.org/protocol/muc#user}x') is not None: + if presence.match('presence/muc'): return jid = presence['from'] status = presence['status'] @@ -1093,12 +824,11 @@ class HandlerCore: if isinstance(self.core.tabs.current_tab, tabs.RosterInfoTab): self.core.refresh_window() - def on_got_online(self, presence): + async def on_got_online(self, presence: Presence): """ A JID got online """ - if presence.match('presence/muc') or presence.xml.find( - '{http://jabber.org/protocol/muc#user}x') is not None: + if presence.match('presence/muc'): return jid = presence['from'] contact = roster[jid.bare] @@ -1115,7 +845,7 @@ class HandlerCore: 'status': presence['status'], 'show': presence['show'], }) - self.core.events.trigger('normal_presence', presence, resource) + await self.core.events.trigger_async('normal_presence', presence, resource) name = contact.name if contact.name else jid.bare self.core.add_information_message_to_conversation_tab( jid.full, '\x195}%s is \x194}online' % name) @@ -1133,7 +863,7 @@ class HandlerCore: if isinstance(self.core.tabs.current_tab, tabs.RosterInfoTab): self.core.refresh_window() - def on_groupchat_presence(self, presence): + async def on_groupchat_presence(self, presence: 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 @@ -1142,44 +872,63 @@ class HandlerCore: from_room = presence['from'].bare tab = self.core.tabs.by_name_and_class(from_room, tabs.MucTab) if tab: - self.core.events.trigger('muc_presence', presence, tab) + await self.core.events.trigger_async('muc_presence', presence, tab) tab.handle_presence(presence) ### Connection-related handlers ### - def on_failed_connection(self, error): + async def on_failed_connection(self, error: str): """ We cannot contact the remote server """ self.core.information( "Connection to remote server failed: %s" % (error, ), 'Error') + async def on_session_end(self, event): + """ + Called when a session is terminated (e.g. due to a manual disconnect or a 0198 resume fail) + """ + roster.connected = 0 + roster.modified() + for tab in self.core.get_tabs(tabs.MucTab): + tab.disconnect() + + async def on_session_resumed(self, event): + """ + Called when a session is successfully resumed by 0198 + """ + self.core.information("Resumed session as %s" % self.core.xmpp.boundjid.full, 'Info') + self.core.xmpp.plugin['xep_0199'].enable_keepalive() + async def on_disconnected(self, event): """ When we are disconnected from remote server """ - if 'disconnect' in config.get('beep_on').split(): + if 'disconnect' in config.getstr('beep_on').split(): curses.beep() - roster.connected = 0 # Stop the ping plugin. It would try to send stanza on regular basis self.core.xmpp.plugin['xep_0199'].disable_keepalive() - roster.modified() - for tab in self.core.get_tabs(tabs.MucTab): - tab.disconnect() msg_typ = 'Error' if not self.core.legitimate_disconnect else 'Info' - self.core.information("Disconnected from server.", msg_typ) - if self.core.legitimate_disconnect or not config.get( - 'auto_reconnect', True): + self.core.information("Disconnected from server%s." % (event and ": %s" % event or ""), msg_typ) + if self.core.legitimate_disconnect or not config.getbool( + 'auto_reconnect'): return if (self.core.last_stream_error and self.core.last_stream_error[1]['condition'] in ( 'conflict', 'host-unknown')): return await asyncio.sleep(1) - self.core.information("Auto-reconnecting.", 'Info') - self.core.xmpp.start() + if not self.core.xmpp.is_connecting() and not self.core.xmpp.is_connected(): + self.core.information("Auto-reconnecting.", 'Info') + self.core.xmpp.start() - def on_stream_error(self, event): + async def on_reconnect_delay(self, event): + """ + When the reconnection is delayed + """ + self.core.information("Reconnecting in %d seconds..." % (event), 'Info') + + async def on_stream_error(self, event): """ When we receive a stream error """ @@ -1188,7 +937,7 @@ class HandlerCore: if event: self.core.last_stream_error = (time.time(), event) - def on_failed_all_auth(self, event): + async def on_failed_all_auth(self, event): """ Authentication failed """ @@ -1196,7 +945,7 @@ class HandlerCore: 'Error') self.core.legitimate_disconnect = True - def on_no_auth(self, event): + async def on_no_auth(self, event): """ Authentication failed (no mech) """ @@ -1204,14 +953,14 @@ class HandlerCore: "Authentication failed, no login method available.", 'Error') self.core.legitimate_disconnect = True - def on_connected(self, event): + async def on_connected(self, event): """ Remote host responded, but we are not yet authenticated """ self.core.information("Connected to server.", 'Info') self.core.legitimate_disconnect = False - def on_session_start(self, event): + async def on_session_start(self, event): """ Called when we are connected and authenticated """ @@ -1226,26 +975,26 @@ class HandlerCore: self.core.xmpp.get_roster() roster.update_contact_groups(self.core.xmpp.boundjid.bare) # send initial presence - if config.get('send_initial_presence'): + if config.getbool('send_initial_presence'): pres = self.core.xmpp.make_presence() pres['show'] = self.core.status.show pres['status'] = self.core.status.message - self.core.events.trigger('send_normal_presence', pres) + await self.core.events.trigger_async('send_normal_presence', pres) pres.send() self.core.bookmarks.get_local() # join all the available bookmarks. As of yet, this is just the local ones - self.core.join_initial_rooms(self.core.bookmarks) + self.core.join_initial_rooms(self.core.bookmarks.local()) - if config.get('enable_user_nick'): + if config.getbool('enable_user_nick'): self.core.xmpp.plugin['xep_0172'].publish_nick( nick=self.core.own_nick, callback=dumb_callback) - asyncio.ensure_future(self.core.xmpp.plugin['xep_0115'].update_caps()) + asyncio.create_task(self.core.xmpp.plugin['xep_0115'].update_caps()) # Start the ping's plugin regular event self.core.xmpp.set_keepalive_values() ### Other handlers ### - def on_status_codes(self, message): + async def on_status_codes(self, message: Message): """ Handle groupchat messages with status codes. Those are received when a room configuration change occurs. @@ -1270,76 +1019,61 @@ class HandlerCore: semi_anon = '173' in status_codes full_anon = '174' in status_codes modif = False + info_col = {'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)} 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 occurred.' - % { - 'info_col': dump_tuple( - get_theme().COLOR_INFORMATION_TEXT) - }, - typ=2) + PersistentInfoMessage( + 'Info: A configuration change not privacy-related occurred.' + ), + ) 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) + PersistentInfoMessage( + 'Info: The unavailable members are now shown.' + ), + ) 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) + PersistentInfoMessage( + 'Info: The unavailable members are now hidden.', + ), + ) 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) + PersistentInfoMessage( + '\x191}Warning:\x19%(info_col)s} The room is now not anonymous. (public JID)' % info_col + ), + ) 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) + PersistentInfoMessage( + 'Info: The room is now semi-anonymous. (moderators-only JID)', + ), + ) 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) + PersistentInfoMessage( + 'Info: The room is now fully anonymous.', + ), + ) 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) + PersistentInfoMessage( + '\x191}Warning: \x19%(info_col)s}This room is publicly logged' % info_col + ), + ) 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) + PersistentInfoMessage( + 'Info: This room is not logged anymore.', + ), + ) if modif: self.core.refresh_window() - def on_groupchat_subject(self, message): + async def on_groupchat_subject(self, message: Message): """ Triggered when the topic is changed. """ @@ -1347,16 +1081,19 @@ class HandlerCore: room_from = message.get_mucroom() tab = self.core.tabs.by_name_and_class(room_from, tabs.MucTab) subject = message['subject'] + time = message['delay']['stamp'] 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. + theme = get_theme() fmt = { - 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT), - 'text_col': dump_tuple(get_theme().COLOR_NORMAL_TEXT), + 'info_col': dump_tuple(theme.COLOR_INFORMATION_TEXT), + 'text_col': dump_tuple(theme.COLOR_NORMAL_TEXT), 'subject': subject, 'user': '', + 'str_time': time, } if nick_from: user = tab.get_user_by_name(nick_from) @@ -1375,23 +1112,25 @@ class HandlerCore: if nick_from: tab.add_message( - "%(user)s set the subject to: \x19%(text_col)s}%(subject)s" - % fmt, - time=None, - typ=2) + PersistentInfoMessage( + "%(user)s set the subject to: \x19%(text_col)s}%(subject)s" % fmt, + time=time, + ), + ) else: tab.add_message( - "\x19%(info_col)s}The subject is: \x19%(text_col)s}%(subject)s" - % fmt, - time=None, - typ=2) + PersistentInfoMessage( + "The subject is: \x19%(text_col)s}%(subject)s" % fmt, + time=time, + ), + ) tab.topic = subject tab.topic_from = nick_from if self.core.tabs.by_name_and_class( room_from, tabs.MucTab) is self.core.tabs.current_tab: self.core.refresh_window() - def on_receipt(self, message): + async def on_receipt(self, message): """ When a delivery receipt is received (XEP-0184) """ @@ -1413,60 +1152,62 @@ class HandlerCore: except AckError: log.debug('Error while receiving an ack', exc_info=True) - def on_data_form(self, message): + async def on_data_form(self, message: Message): """ When a data form is received """ self.core.information(str(message)) - def on_attention(self, message): + async def on_attention(self, message: Message): """ Attention probe received. """ jid_from = message['from'] self.core.information('%s requests your attention!' % jid_from, 'Info') - for tab in self.core.tabs: - if tab.name == jid_from: - tab.state = 'attention' - self.core.refresh_tab_win() - return - for tab in self.core.tabs: - if tab.name == jid_from.bare: - tab.state = 'attention' - self.core.refresh_tab_win() - return - self.core.information('%s tab not found.' % jid_from, 'Error') + tab = ( + self.core.tabs.by_name_and_class( + jid_from.full, tabs.ChatTab + ) or self.core.tabs.by_name_and_class( + jid_from.bare, tabs.ChatTab + ) + ) + if tab and tab is not self.core.tabs.current_tab: + tab.state = "attention" + self.core.refresh_tab_win() - def outgoing_stanza(self, stanza): + def outgoing_stanza(self, stanza: StanzaBase): """ We are sending a new stanza, write it in the xml buffer if needed. """ if self.core.xml_tab: + stanza_str = str(stanza) if PYGMENTS: - xhtml_text = highlight(str(stanza), LEXER, FORMATTER) + xhtml_text = highlight(stanza_str, LEXER, FORMATTER) poezio_colored = xhtml.xhtml_to_poezio_colors( xhtml_text, force=True).rstrip('\x19o').strip() else: - poezio_colored = str(stanza) - self.core.add_message_to_text_buffer( - self.core.xml_buffer, - poezio_colored, - nickname=get_theme().CHAR_XML_OUT) + poezio_colored = stanza_str + self.core.xml_buffer.add_message( + XMLLog(txt=poezio_colored, incoming=False), + ) try: if self.core.xml_tab.match_stanza( - ElementBase(ET.fromstring(stanza))): - self.core.add_message_to_text_buffer( - self.core.xml_tab.filtered_buffer, - poezio_colored, - nickname=get_theme().CHAR_XML_OUT) + ElementBase(ET.fromstring(stanza_str))): + self.core.xml_tab.filtered_buffer.add_message( + XMLLog(txt=poezio_colored, incoming=False), + ) except: + # Most of the time what gets logged is whitespace pings. Skip. + # And also skip tab updates. + if stanza_str.strip() == '': + return None log.debug('', exc_info=True) if isinstance(self.core.tabs.current_tab, tabs.XMLTab): self.core.tabs.current_tab.refresh() self.core.doupdate() - def incoming_stanza(self, stanza): + def incoming_stanza(self, stanza: StanzaBase): """ We are receiving a new stanza, write it in the xml buffer if needed. """ @@ -1477,16 +1218,14 @@ class HandlerCore: xhtml_text, force=True).rstrip('\x19o').strip() else: poezio_colored = str(stanza) - self.core.add_message_to_text_buffer( - self.core.xml_buffer, - poezio_colored, - nickname=get_theme().CHAR_XML_IN) + self.core.xml_buffer.add_message( + XMLLog(txt=poezio_colored, incoming=True), + ) try: if self.core.xml_tab.match_stanza(stanza): - self.core.add_message_to_text_buffer( - self.core.xml_tab.filtered_buffer, - poezio_colored, - nickname=get_theme().CHAR_XML_IN) + self.core.xml_tab.filtered_buffer.add_message( + XMLLog(txt=poezio_colored, incoming=True), + ) except: log.debug('', exc_info=True) if isinstance(self.core.tabs.current_tab, tabs.XMLTab): @@ -1525,19 +1264,24 @@ class HandlerCore: self.core.add_tab(confirm_tab, True) self.core.doupdate() + # handle resize + prev_value = signal.signal(signal.SIGWINCH, self.core.sigwinch_handler) while not confirm_tab.done: - sel = select.select([sys.stdin], [], [], 5)[0] - - if sel: - self.core.on_input_readable() + try: + sel = select.select([sys.stdin], [], [], 0.5)[0] + if sel: + self.core.on_input_readable() + except: + continue + signal.signal(signal.SIGWINCH, prev_value) def validate_ssl(self, pem): """ Check the server certificate using the slixmpp ssl_cert event """ - if config.get('ignore_certificate'): + if config.getbool('ignore_certificate'): return - cert = config.get('certificate') + cert = config.getstr('certificate') # update the cert representation when it uses the old one if cert and ':' not in cert: cert = ':'.join( @@ -1646,7 +1390,7 @@ class HandlerCore: def adhoc_error(self, iq, adhoc_session): self.core.xmpp.plugin['xep_0050'].terminate_command(adhoc_session) - error_message = self.core.get_error_message(iq) + error_message = get_error_message(iq) self.core.information( "An error occurred while executing the command: %s" % (error_message), 'Error') @@ -1679,7 +1423,7 @@ def _composing_tab_state(tab, state): else: return # should not happen - show = config.get('show_composing_tabs') + show = config.getstr('show_composing_tabs').lower() show = show in values if tab.state != 'composing' and state == 'composing': diff --git a/poezio/core/structs.py b/poezio/core/structs.py index 72c9628a..31d31339 100644 --- a/poezio/core/structs.py +++ b/poezio/core/structs.py @@ -1,45 +1,20 @@ """ Module defining structures useful to the core class and related methods """ +from __future__ import annotations +from dataclasses import dataclass +from typing import Any, Callable, List, TYPE_CHECKING, Optional + +if TYPE_CHECKING: + from poezio import windows __all__ = [ - 'ERROR_AND_STATUS_CODES', 'DEPRECATED_ERRORS', 'POSSIBLE_SHOW', 'Status', - 'Command', 'Completion' + 'Command', + 'Completion', + 'POSSIBLE_SHOW', + 'Status', ] -# 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', @@ -51,23 +26,11 @@ POSSIBLE_SHOW = { } +@dataclass class Status: __slots__ = ('show', 'message') - - def __init__(self, show, message): - self.show = show - self.message = message - - -class Command: - __slots__ = ('func', 'desc', 'comp', 'short_desc', 'usage') - - def __init__(self, func, desc, comp, short_desc, usage): - self.func = func - self.desc = desc - self.comp = comp - self.short_desc = short_desc - self.usage = usage + show: str + message: str class Completion: @@ -76,7 +39,13 @@ class Completion: """ __slots__ = ['func', 'args', 'kwargs', 'comp_list'] - def __init__(self, func, comp_list, *args, **kwargs): + def __init__( + self, + func: Callable[..., Any], + comp_list: List[str], + *args: Any, + **kwargs: Any + ) -> None: self.func = func self.comp_list = comp_list self.args = args @@ -84,3 +53,13 @@ class Completion: def run(self): return self.func(self.comp_list, *self.args, **self.kwargs) + + +@dataclass +class Command: + __slots__ = ('func', 'desc', 'comp', 'short_desc', 'usage') + func: Callable[..., Any] + desc: str + comp: Optional[Callable[['windows.Input'], Completion]] + short_desc: str + usage: str diff --git a/poezio/core/tabs.py b/poezio/core/tabs.py index 3ced7a7e..6d0589ba 100644 --- a/poezio/core/tabs.py +++ b/poezio/core/tabs.py @@ -24,11 +24,14 @@ have become [0|1|2|3], with the tab "4" renumbered to "3" if gap tabs are disabled. """ -from typing import List, Dict, Type, Optional, Union +from typing import List, Dict, Type, Optional, Union, Tuple, TypeVar, cast from collections import defaultdict +from slixmpp import JID from poezio import tabs from poezio.events import EventHandler +T = TypeVar('T', bound=tabs.Tab) + class Tabs: """ @@ -38,28 +41,29 @@ class Tabs: '_current_index', '_current_tab', '_tabs', + '_tab_jids', '_tab_types', '_tab_names', '_previous_tab', '_events', ] - def __init__(self, events: EventHandler) -> None: + def __init__(self, events: EventHandler, initial_tab: tabs.Tab) -> None: """ Initialize the Tab List. Even though the list is initially empty, all methods are only valid once append() has been called once. Otherwise, mayhem is expected. """ # cursor - self._current_index = 0 # type: int - self._current_tab = None # type: Optional[tabs.Tab] + self._current_index: int = 0 + self._current_tab: tabs.Tab = initial_tab - self._previous_tab = None # type: Optional[tabs.Tab] - self._tabs = [] # type: List[tabs.Tab] - self._tab_types = defaultdict( - list) # type: Dict[Type[tabs.Tab], List[tabs.Tab]] - self._tab_names = dict() # type: Dict[str, tabs.Tab] - self._events = events # type: EventHandler + self._previous_tab: Optional[tabs.Tab] = None + self._tabs: List[tabs.Tab] = [] + self._tab_jids: Dict[JID, tabs.Tab] = dict() + self._tab_types: Dict[Type[tabs.Tab], List[tabs.Tab]] = defaultdict(list) + self._tab_names: Dict[str, tabs.Tab] = dict() + self._events: EventHandler = events def __len__(self): return len(self._tabs) @@ -89,7 +93,7 @@ class Tabs: return False @property - def current_tab(self) -> Optional[tabs.Tab]: + def current_tab(self) -> tabs.Tab: """Current tab""" return self._current_tab @@ -111,13 +115,17 @@ class Tabs: """Return the tab list""" return self._tabs + def by_jid(self, jid: JID) -> Optional[tabs.Tab]: + """Get a tab with a specific jid""" + return self._tab_jids.get(jid) + def by_name(self, name: str) -> Optional[tabs.Tab]: """Get a tab with a specific name""" return self._tab_names.get(name) - def by_class(self, cls: Type[tabs.Tab]) -> List[tabs.Tab]: + def by_class(self, cls: Type[T]) -> List[T]: """Get all the tabs of a class""" - return self._tab_types.get(cls, []) + return cast(List[T], self._tab_types.get(cls, [])) def find_match(self, name: str) -> Optional[tabs.Tab]: """Get a tab using extended matching (tab.matching_name())""" @@ -132,21 +140,60 @@ class Tabs: return self._tabs[i] return None - def by_name_and_class(self, name: str, - cls: Type[tabs.Tab]) -> Optional[tabs.Tab]: + def find_by_unique_prefix(self, prefix: str) -> Tuple[bool, Optional[tabs.Tab]]: + """ + Get a tab by its unique name prefix, ignoring case. + + :return: A tuple indicating the presence of any match, as well as the + uniquely matched tab (if any). + + The first element, a boolean, in the returned tuple indicates whether + at least one tab matched. + + The second element (a Tab) in the returned tuple is the uniquely + matched tab, if any. If multiple or no tabs match the prefix, the + second element in the tuple is :data:`None`. + """ + + # TODO: should this maybe use something smarter than .lower()? + # something something stringprep? + prefix = prefix.lower() + candidate = None + any_matched = False + for tab in self._tabs: + if not tab.name.lower().startswith(prefix): + continue + any_matched = True + if candidate is not None: + # multiple tabs match -> return None + return True, None + candidate = tab + + return any_matched, candidate + + def by_name_and_class(self, name: Union[str, JID], + cls: Type[T]) -> Optional[T]: """Get a tab with its name and class""" + if isinstance(name, JID): + str_name = name.full + else: + str_name = name + str cls_tabs = self._tab_types.get(cls, []) for tab in cls_tabs: - if tab.name == name: - return tab + if tab.name == str_name: + return cast(T, tab) return None def _rebuild(self): + self._tab_jids = dict() self._tab_types = defaultdict(list) self._tab_names = dict() for tab in self._tabs: for cls in _get_tab_types(tab): self._tab_types[cls].append(tab) + if hasattr(tab, 'jid'): + self._tab_jids[tab.jid] = tab # type: ignore self._tab_names[tab.name] = tab self._update_numbers() @@ -206,6 +253,8 @@ class Tabs: self._tabs.append(tab) for cls in _get_tab_types(tab): self._tab_types[cls].append(tab) + if hasattr(tab, 'jid'): + self._tab_jids[tab.jid] = tab # type: ignore self._tab_names[tab.name] = tab def delete(self, tab: tabs.Tab, gap=False): @@ -214,7 +263,7 @@ class Tabs: return if gap: - self._tabs[tab.nb] = tabs.GapTab(None) + self._tabs[tab.nb] = tabs.GapTab() else: self._tabs.remove(tab) @@ -222,6 +271,8 @@ class Tabs: for cls in _get_tab_types(tab): self._tab_types[cls].remove(tab) + if hasattr(tab, 'jid'): + del self._tab_jids[tab.jid] # type: ignore del self._tab_names[tab.name] if gap: @@ -233,6 +284,7 @@ class Tabs: self._previous_tab = None if is_current: self.restore_previous_tab() + self._previous_tab = None self._validate_current_index() def restore_previous_tab(self): @@ -247,7 +299,7 @@ class Tabs: def _validate_current_index(self): if not 0 <= self._current_index < len( self._tabs) or not self.current_tab: - self.prev() + self.prev() # pylint: disable=not-callable def _collect_trailing_gaptabs(self): """Remove trailing gap tabs if any""" @@ -300,16 +352,16 @@ class Tabs: if new_pos < len(self._tabs): old_tab = self._tabs[old_pos] self._tabs[new_pos], self._tabs[ - old_pos] = old_tab, tabs.GapTab(self) + old_pos] = old_tab, tabs.GapTab() else: self._tabs.append(self._tabs[old_pos]) - self._tabs[old_pos] = tabs.GapTab(self) + self._tabs[old_pos] = tabs.GapTab() else: if new_pos > old_pos: self._tabs.insert(new_pos, tab) - self._tabs[old_pos] = tabs.GapTab(self) + self._tabs[old_pos] = tabs.GapTab() elif new_pos < old_pos: - self._tabs[old_pos] = tabs.GapTab(self) + self._tabs[old_pos] = tabs.GapTab() self._tabs.insert(new_pos, tab) else: return False diff --git a/poezio/daemon.py b/poezio/daemon.py index c8225a07..7a67a12d 100755 --- a/poezio/daemon.py +++ b/poezio/daemon.py @@ -4,7 +4,7 @@ # 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. +# it under the terms of the GPL-3.0+ 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). diff --git a/poezio/decorators.py b/poezio/decorators.py index bf1c2ebe..9342161f 100644 --- a/poezio/decorators.py +++ b/poezio/decorators.py @@ -1,54 +1,106 @@ """ Module containing various decorators """ -from typing import Any, Callable, List, Optional + +from __future__ import annotations +from asyncio import iscoroutinefunction + +from typing import ( + cast, + Any, + Callable, + Dict, + List, + Optional, + TypeVar, + TYPE_CHECKING, +) from poezio import common +if TYPE_CHECKING: + from poezio.core.core import Core + + +T = TypeVar('T', bound=Callable[..., Any]) + + +BeforeFunc = Optional[Callable[[List[Any], Dict[str, Any]], Any]] +AfterFunc = Optional[Callable[[Any, List[Any], Dict[str, Any]], Any]] + + +def wrap_generic(func: Callable, before: BeforeFunc = None, after: AfterFunc = None): + """ + Generic wrapper which can both wrap coroutines and normal functions. + """ + def wrap(*args, **kwargs): + args = list(args) + if before is not None: + result = before(args, kwargs) + if result is not None: + return result + result = func(*args, **kwargs) + if after is not None: + result = after(result, args, kwargs) + return result + + async def awrap(*args, **kwargs): + args = list(args) + if before is not None: + result = before(args, kwargs) + if result is not None: + return result + result = await func(*args, **kwargs) + if after is not None: + result = after(result, args, kwargs) + return result + if iscoroutinefunction(func): + return awrap + return wrap class RefreshWrapper: - def __init__(self): + core: Optional[Core] + + def __init__(self) -> None: self.core = None - def conditional(self, func: Callable) -> Callable: + def conditional(self, func: T) -> T: """ Decorator to refresh the UI if the wrapped function returns True """ + def after(result: Any, args, kwargs) -> Any: + if self.core is not None and result: + self.core.refresh_window() # pylint: disable=no-member + return result - def wrap(*args, **kwargs): - ret = func(*args, **kwargs) - if self.core and ret: - self.core.refresh_window() - return ret + wrap = wrap_generic(func, after=after) - return wrap + return cast(T, wrap) - def always(self, func: Callable) -> Callable: + def always(self, func: T) -> T: """ Decorator that refreshs the UI no matter what after the function """ + def after(result: Any, args, kwargs) -> Any: + if self.core is not None: + self.core.refresh_window() # pylint: disable=no-member + return result - def wrap(*args, **kwargs): - ret = func(*args, **kwargs) - if self.core: - self.core.refresh_window() - return ret - - return wrap + wrap = wrap_generic(func, after=after) + return cast(T, wrap) - def update(self, func: Callable) -> Callable: + def update(self, func: T) -> T: """ Decorator that only updates the screen """ - def wrap(*args, **kwargs): - ret = func(*args, **kwargs) - if self.core: - self.core.doupdate() - return ret - - return wrap + def after(result: Any, args, kwargs) -> Any: + if self.core is not None: + self.core.doupdate() # pylint: disable=no-member + return result + wrap = wrap_generic(func, after=after) + return cast(T, wrap) refresh_wrapper = RefreshWrapper() @@ -61,48 +113,45 @@ class CommandArgParser: """ @staticmethod - def raw(func: Callable) -> Callable: + def raw(func: T) -> T: """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 + return func @staticmethod - def ignored(func: Callable) -> Callable: + def ignored(func: T) -> T: """ - Call the function without any argument + Call the function without textual arguments """ + def before(args: List[Any], kwargs: Dict[Any, Any]) -> None: + if len(args) >= 2: + del args[1] - def wrap(self, args=None, *a, **kw): - return func(self, *a, **kw) - - return wrap + wrap = wrap_generic(func, before=before) + return cast(T, wrap) @staticmethod def quoted(mandatory: int, - optional=0, + optional: int = 0, defaults: Optional[List[Any]] = None, - ignore_trailing_arguments=False): + ignore_trailing_arguments: bool = False) -> Callable[[T], T]: """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. + 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. + we append them to the last argument of the list. - An argument is a string (with or without whitespaces) between to quotes + An argument is a string (with or without whitespaces) between two 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` + argument and none is provided, 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 @@ -131,15 +180,17 @@ class CommandArgParser: """ default_args_outer = defaults or [] - def first(func: Callable): - def second(self, args: str, *a, **kw): + def first(func: T) -> T: + def before(args: List, kwargs: Dict[str, Any]) -> Any: default_args = default_args_outer - if args and args.strip(): - split_args = common.shell_split(args) + cmdargs = args[1] + if cmdargs and cmdargs.strip(): + split_args = common.shell_split(cmdargs) else: split_args = [] if len(split_args) < mandatory: - return func(self, None, *a, **kw) + args[1] = None + return res, split_args = split_args[:mandatory], split_args[ mandatory:] if optional == -1: @@ -154,11 +205,25 @@ class CommandArgParser: res += default_args if split_args and res and not ignore_trailing_arguments: res[-1] += " " + " ".join(split_args) - return func(self, res, *a, **kw) + args[1] = res + return + wrap = wrap_generic(func, before=before) + return cast(T, wrap) + return first - return second +command_args_parser = CommandArgParser() - return first +def deny_anonymous(func: T) -> T: + """Decorator to disable commands when using an anonymous account.""" -command_args_parser = CommandArgParser() + def before(args: Any, kwargs: Any) -> Any: + core = args[0].core + if core.xmpp.anon: + core.information( + 'This command is not available for anonymous accounts.', + 'Info' + ) + return False + wrap = wrap_generic(func, before=before) + return cast(T, wrap) diff --git a/poezio/events.py b/poezio/events.py index 3bfe5156..0ba97d56 100644 --- a/poezio/events.py +++ b/poezio/events.py @@ -2,15 +2,20 @@ # 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. +# it under the terms of the GPL-3.0+ 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 """ +import logging +from collections import OrderedDict +from inspect import iscoroutinefunction from typing import Callable, Dict, List +log = logging.getLogger(__name__) + class EventHandler: """ @@ -21,52 +26,73 @@ class EventHandler: """ 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': [], - } # type: Dict[str, List[Callable]] + 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', + ] + self.events: Dict[str, OrderedDict[int, List[Callable]]] = {} + for event in events: + self.events[event] = OrderedDict() def add_event_handler(self, name: str, callback: Callable, - position=0) -> bool: + priority: int = 50) -> bool: """ 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 + priority is a integer between 0 and 100. 0 is the highest priority and + will be called first. 100 is the lowest. """ + if name not in self.events: return False callbacks = self.events[name] - if position >= 0: - callbacks.insert(position, callback) - else: - callbacks.append(callback) + + # Clamp priority + priority = max(0, min(priority, 100)) + + entry = callbacks.setdefault(priority, []) + entry.append(callback) return True + async def trigger_async(self, name: str, *args, **kwargs): + """ + Call all the callbacks associated to the given event name. + """ + callbacks = self.events.get(name, None) + if callbacks is None: + return + for priority in callbacks.values(): + for callback in priority: + if iscoroutinefunction(callback): + await callback(*args, **kwargs) + else: + callback(*args, **kwargs) + def trigger(self, name: str, *args, **kwargs): """ Call all the callbacks associated to the given event name. @@ -74,8 +100,13 @@ class EventHandler: callbacks = self.events.get(name, None) if callbacks is None: return - for callback in callbacks: - callback(*args, **kwargs) + for priority in callbacks.values(): + for callback in priority: + if not iscoroutinefunction(callback): + callback(*args, **kwargs) + else: + log.error(f'async event handler {callback} ' + 'called in sync trigger!') def del_event_handler(self, name: str, callback: Callable): """ @@ -83,9 +114,13 @@ class EventHandler: """ if not name: for callbacks in self.events.values(): - while callback in callbacks: - callbacks.remove(callback) + for priority in callbacks.values(): + for entry in priority[:]: + if entry == callback: + priority.remove(callback) else: callbacks = self.events[name] - if callback in callbacks: - callbacks.remove(callback) + for priority in callbacks.values(): + for entry in priority[:]: + if entry == callback: + priority.remove(callback) diff --git a/poezio/fixes.py b/poezio/fixes.py index f8de7b14..c2db4332 100644 --- a/poezio/fixes.py +++ b/poezio/fixes.py @@ -5,44 +5,15 @@ upstream. TODO: Check that they are fixed and remove those hacks """ -from slixmpp.stanza import Message -from slixmpp.xmlstream import ET +from slixmpp import Message +from slixmpp.plugins.xep_0184 import XEP_0184 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_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'].build_form(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): +def _filter_add_receipt_request(self: XEP_0184, stanza): """ Auto add receipt requests to outgoing messages, if: diff --git a/poezio/hsluv.py b/poezio/hsluv.py new file mode 100644 index 00000000..7dce5061 --- /dev/null +++ b/poezio/hsluv.py @@ -0,0 +1,360 @@ +# This file was taken from https://github.com/hsluv/hsluv-python +# +# Copyright (c) 2015 Alexei Boronine +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +""" This module is generated by transpiling Haxe into Python and cleaning +the resulting code by hand, e.g. removing unused Haxe classes. To try it +yourself, clone https://github.com/hsluv/hsluv and run: + + haxe -cp haxe/src hsluv.Hsluv -python hsluv.py +""" + +import math + + + +__version__ = '0.0.2' + +m = [[3.240969941904521, -1.537383177570093, -0.498610760293], + [-0.96924363628087, 1.87596750150772, 0.041555057407175], + [0.055630079696993, -0.20397695888897, 1.056971514242878]] +minv = [[0.41239079926595, 0.35758433938387, 0.18048078840183], + [0.21263900587151, 0.71516867876775, 0.072192315360733], + [0.019330818715591, 0.11919477979462, 0.95053215224966]] +refY = 1.0 +refU = 0.19783000664283 +refV = 0.46831999493879 +kappa = 903.2962962 +epsilon = 0.0088564516 +hex_chars = "0123456789abcdef" + + +def _distance_line_from_origin(line): + v = math.pow(line['slope'], 2) + 1 + return math.fabs(line['intercept']) / math.sqrt(v) + + +def _length_of_ray_until_intersect(theta, line): + return line['intercept'] / (math.sin(theta) - line['slope'] * math.cos(theta)) + + +def _get_bounds(l): + result = [] + sub1 = math.pow(l + 16, 3) / 1560896 + if sub1 > epsilon: + sub2 = sub1 + else: + sub2 = l / kappa + _g = 0 + while _g < 3: + c = _g + _g = _g + 1 + m1 = m[c][0] + m2 = m[c][1] + m3 = m[c][2] + _g1 = 0 + while _g1 < 2: + t = _g1 + _g1 = _g1 + 1 + top1 = (284517 * m1 - 94839 * m3) * sub2 + top2 = (838422 * m3 + 769860 * m2 + 731718 * m1) * l * sub2 - (769860 * t) * l + bottom = (632260 * m3 - 126452 * m2) * sub2 + 126452 * t + result.append({'slope': top1 / bottom, 'intercept': top2 / bottom}) + return result + + +def _max_safe_chroma_for_l(l): + bounds = _get_bounds(l) + _hx_min = 1.7976931348623157e+308 + _g = 0 + while _g < 2: + i = _g + _g = _g + 1 + length = _distance_line_from_origin(bounds[i]) + if math.isnan(_hx_min): + _hx_min = _hx_min + elif math.isnan(length): + _hx_min = length + else: + _hx_min = min(_hx_min, length) + return _hx_min + + +def _max_chroma_for_lh(l, h): + hrad = h / 360 * math.pi * 2 + bounds = _get_bounds(l) + _hx_min = 1.7976931348623157e+308 + _g = 0 + while _g < len(bounds): + bound = bounds[_g] + _g = (_g + 1) + length = _length_of_ray_until_intersect(hrad, bound) + if length >= 0: + if math.isnan(_hx_min): + _hx_min = _hx_min + elif math.isnan(length): + _hx_min = length + else: + _hx_min = min(_hx_min, length) + return _hx_min + + +def _dot_product(a, b): + sum = 0 + _g1 = 0 + _g = len(a) + while _g1 < _g: + i = _g1 + _g1 = _g1 + 1 + sum += a[i] * b[i] + return sum + + +def _from_linear(c): + if c <= 0.0031308: + return 12.92 * c + else: + return 1.055 * math.pow(c, 0.416666666666666685) - 0.055 + + +def _to_linear(c): + if c > 0.04045: + return math.pow((c + 0.055) / 1.055, 2.4) + else: + return c / 12.92 + + +def xyz_to_rgb(_hx_tuple): + return [ + _from_linear(_dot_product(m[0], _hx_tuple)), + _from_linear(_dot_product(m[1], _hx_tuple)), + _from_linear(_dot_product(m[2], _hx_tuple))] + + +def rgb_to_xyz(_hx_tuple): + rgbl = [_to_linear(_hx_tuple[0]), + _to_linear(_hx_tuple[1]), + _to_linear(_hx_tuple[2])] + return [_dot_product(minv[0], rgbl), + _dot_product(minv[1], rgbl), + _dot_product(minv[2], rgbl)] + + +def _y_to_l(y): + if y <= epsilon: + return y / refY * kappa + else: + return 116 * math.pow(y / refY, 0.333333333333333315) - 16 + + +def _l_to_y(l): + if l <= 8: + return refY * l / kappa + else: + return refY * math.pow((l + 16) / 116, 3) + + +def xyz_to_luv(_hx_tuple): + x = float(_hx_tuple[0]) + y = float(_hx_tuple[1]) + z = float(_hx_tuple[2]) + divider = x + 15 * y + 3 * z + var_u = 4 * x + var_v = 9 * y + if divider != 0: + var_u = var_u / divider + var_v = var_v / divider + else: + var_u = float("nan") + var_v = float("nan") + l = _y_to_l(y) + if l == 0: + return [0, 0, 0] + u = 13 * l * (var_u - refU) + v = 13 * l * (var_v - refV) + return [l, u, v] + + +def luv_to_xyz(_hx_tuple): + l = float(_hx_tuple[0]) + u = float(_hx_tuple[1]) + v = float(_hx_tuple[2]) + if l == 0: + return [0, 0, 0] + var_u = u / (13 * l) + refU + var_v = v / (13 * l) + refV + y = _l_to_y(l) + x = 0 - ((9 * y * var_u) / (((var_u - 4) * var_v) - var_u * var_v)) + z = (((9 * y) - (15 * var_v * y)) - (var_v * x)) / (3 * var_v) + return [x, y, z] + + +def luv_to_lch(_hx_tuple): + l = float(_hx_tuple[0]) + u = float(_hx_tuple[1]) + v = float(_hx_tuple[2]) + _v = (u * u) + (v * v) + if _v < 0: + c = float("nan") + else: + c = math.sqrt(_v) + if c < 0.00000001: + h = 0 + else: + hrad = math.atan2(v, u) + h = hrad * 180.0 / 3.1415926535897932 + if h < 0: + h = 360 + h + return [l, c, h] + + +def lch_to_luv(_hx_tuple): + l = float(_hx_tuple[0]) + c = float(_hx_tuple[1]) + h = float(_hx_tuple[2]) + hrad = h / 360.0 * 2 * math.pi + u = math.cos(hrad) * c + v = math.sin(hrad) * c + return [l, u, v] + + +def hsluv_to_lch(_hx_tuple): + h = float(_hx_tuple[0]) + s = float(_hx_tuple[1]) + l = float(_hx_tuple[2]) + if l > 99.9999999: + return [100, 0, h] + if l < 0.00000001: + return [0, 0, h] + _hx_max = _max_chroma_for_lh(l, h) + c = _hx_max / 100 * s + return [l, c, h] + + +def lch_to_hsluv(_hx_tuple): + l = float(_hx_tuple[0]) + c = float(_hx_tuple[1]) + h = float(_hx_tuple[2]) + if l > 99.9999999: + return [h, 0, 100] + if l < 0.00000001: + return [h, 0, 0] + _hx_max = _max_chroma_for_lh(l, h) + s = c / _hx_max * 100 + return [h, s, l] + + +def hpluv_to_lch(_hx_tuple): + h = float(_hx_tuple[0]) + s = float(_hx_tuple[1]) + l = float(_hx_tuple[2]) + if l > 99.9999999: + return [100, 0, h] + if l < 0.00000001: + return [0, 0, h] + _hx_max = _max_safe_chroma_for_l(l) + c = _hx_max / 100 * s + return [l, c, h] + + +def lch_to_hpluv(_hx_tuple): + l = float(_hx_tuple[0]) + c = float(_hx_tuple[1]) + h = float(_hx_tuple[2]) + if l > 99.9999999: + return [h, 0, 100] + if l < 0.00000001: + return [h, 0, 0] + _hx_max = _max_safe_chroma_for_l(l) + s = c / _hx_max * 100 + return [h, s, l] + + +def rgb_to_hex(_hx_tuple): + h = "#" + _g = 0 + while _g < 3: + i = _g + _g = _g + 1 + chan = float(_hx_tuple[i]) + c = math.floor(chan * 255 + 0.5) + digit2 = int(c % 16) + digit1 = int((c - digit2) / 16) + + h += hex_chars[digit1] + hex_chars[digit2] + return h + + +def hex_to_rgb(hex): + hex = hex.lower() + ret = [] + _g = 0 + while _g < 3: + i = _g + _g = _g + 1 + index = i * 2 + 1 + _hx_str = hex[index] + digit1 = hex_chars.find(_hx_str) + index1 = i * 2 + 2 + str1 = hex[index1] + digit2 = hex_chars.find(str1) + n = digit1 * 16 + digit2 + ret.append(n / 255.0) + return ret + + +def lch_to_rgb(_hx_tuple): + return xyz_to_rgb(luv_to_xyz(lch_to_luv(_hx_tuple))) + + +def rgb_to_lch(_hx_tuple): + return luv_to_lch(xyz_to_luv(rgb_to_xyz(_hx_tuple))) + + +def hsluv_to_rgb(_hx_tuple): + return lch_to_rgb(hsluv_to_lch(_hx_tuple)) + + +def rgb_to_hsluv(_hx_tuple): + return lch_to_hsluv(rgb_to_lch(_hx_tuple)) + + +def hpluv_to_rgb(_hx_tuple): + return lch_to_rgb(hpluv_to_lch(_hx_tuple)) + + +def rgb_to_hpluv(_hx_tuple): + return lch_to_hpluv(rgb_to_lch(_hx_tuple)) + + +def hsluv_to_hex(_hx_tuple): + return rgb_to_hex(hsluv_to_rgb(_hx_tuple)) + + +def hpluv_to_hex(_hx_tuple): + return rgb_to_hex(hpluv_to_rgb(_hx_tuple)) + + +def hex_to_hsluv(s): + return rgb_to_hsluv(hex_to_rgb(s)) + + +def hex_to_hpluv(s): + return rgb_to_hpluv(hex_to_rgb(s)) diff --git a/poezio/keyboard.py b/poezio/keyboard.py index 3d8e8d5c..1e75b2a2 100755 --- a/poezio/keyboard.py +++ b/poezio/keyboard.py @@ -4,7 +4,7 @@ # 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. +# it under the terms of the GPL-3.0+ license. See the COPYING file. """ Functions to interact with the keyboard Mainly, read keys entered and return a string (most @@ -26,7 +26,7 @@ log = logging.getLogger(__name__) # 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 # type: Optional[Callable] +continuation_keys_callback: Optional[Callable] = None def get_next_byte(s) -> Tuple[Optional[int], Optional[bytes]]: @@ -46,7 +46,7 @@ def get_next_byte(s) -> Tuple[Optional[int], Optional[bytes]]: def get_char_list(s) -> List[str]: - ret_list = [] # type: List[str] + ret_list: List[str] = [] while True: try: key = s.get_wch() diff --git a/poezio/log_loader.py b/poezio/log_loader.py new file mode 100644 index 00000000..2e3b27c2 --- /dev/null +++ b/poezio/log_loader.py @@ -0,0 +1,395 @@ +""" +This modules contains a class that loads messages into a ChatTab, either from +MAM or the local logs, and a class that loads MUC history into the local +logs. + + +How the log loading works will depend on the poezio configuration: + +- if use_log is True, no logs will be fetched dynamically +- if use_log is False, all logs will be fetched from MAM (if available) +- if mam_sync and use_log are True, most chat tabs (all of them except the + static conversation tab) will try to sync the local + logs with the MAM history when opening them, or when joining a room. +- all log loading/writing workflows are paused until the MAM sync is complete + (so that the local log loading can be up-to-date with the MAM history) +- when use_log is False, mam_sync has no effect +""" +from __future__ import annotations +import asyncio +import logging +from datetime import datetime, timedelta, timezone +from typing import List, Optional +from poezio import tabs +from poezio.logger import ( + build_log_message, + iterate_messages_reverse, + last_message_in_archive, + Logger, + LogDict, +) +from poezio.mam import ( + fetch_history, + NoMAMSupportException, + MAMQueryException, + DiscoInfoException, + make_line, +) +from poezio.common import to_utc +from poezio.ui.types import EndOfArchive, Message, BaseMessage +from poezio.text_buffer import HistoryGap +from slixmpp import JID + + +# Max number of messages to insert when filling a gap +HARD_LIMIT = 999 + + +log = logging.getLogger(__name__) + + +def make_line_local(tab: tabs.ChatTab, msg: LogDict) -> Message: + """Create a UI message from a local log read. + + :param tab: Tab in which that message will be displayed + :param msg: Log data + :returns: The UI message + """ + if isinstance(tab, tabs.MucTab): + jid = JID(tab.jid) + jid.resource = msg.get('nickname') or '' + else: + jid = JID(tab.jid) + msg['time'] = msg['time'].astimezone(tz=timezone.utc) + return make_line(tab, msg['txt'], msg['time'], jid, '', msg['nickname']) + + +class LogLoader: + """ + An ephemeral class that loads history in a tab. + + Loading from local logs is blocked until history has been fetched from + MAM to fill the local archive. + """ + logger: Logger + tab: tabs.ChatTab + mam_only: bool + + def __init__(self, logger: Logger, tab: tabs.ChatTab, + local_logs: bool = True, + done_event: Optional[asyncio.Event] = None): + self.mam_only = not local_logs + self.logger = logger + self.tab = tab + self.done_event = done_event + + def _done(self) -> None: + """Signal end if possible""" + if self.done_event is not None: + self.done_event.set() + + async def tab_open(self) -> None: + """Called on a tab opening or a MUC join""" + amount = 2 * self.tab.text_win.height + gap = self.tab._text_buffer.find_last_gap_muc() + messages = [] + if gap is not None: + if self.mam_only: + messages = await self.mam_fill_gap(gap, amount) + else: + messages = await self.local_fill_gap(gap, amount) + else: + if self.mam_only: + messages = await self.mam_tab_open(amount) + else: + messages = await self.local_tab_open(amount) + + log.debug( + 'Fetched %s messages for %s', + len(messages), self.tab.jid + ) + if messages: + self.tab._text_buffer.add_history_messages(messages) + self.tab.core.refresh_window() + self._done() + + async def mam_tab_open(self, nb: int) -> List[BaseMessage]: + """Fetch messages in MAM when opening a new tab. + + :param nb: number of max messages to fetch. + :returns: list of ui messages to add + """ + tab = self.tab + end = datetime.now() + for message in tab._text_buffer.messages: + time_ok = to_utc(message.time) < to_utc(end) + if isinstance(message, Message) and time_ok: + end = message.time + break + end = end - timedelta(microseconds=1) + try: + return await fetch_history(tab, end=end, amount=nb) + except (NoMAMSupportException, MAMQueryException, DiscoInfoException): + return [] + finally: + tab.query_status = False + + def _get_time_limit(self) -> datetime: + """Get the date 10 weeks ago from now.""" + return datetime.now() - timedelta(weeks=10) + + async def local_tab_open(self, nb: int) -> List[BaseMessage]: + """Fetch messages locally when opening a new tab. + + :param nb: number of max messages to fetch. + :returns: list of ui messages to add + """ + await self.wait_mam() + limit = self._get_time_limit() + results: List[BaseMessage] = [] + filepath = self.logger.get_file_path(self.tab.jid) + count = 0 + for msg in iterate_messages_reverse(filepath): + typ_ = msg.pop('type') + if typ_ == 'message': + results.append(make_line_local(self.tab, msg)) + elif msg['time'] < limit and 'set the subject' not in msg['txt']: + break + if len(results) >= nb: + break + count += 1 + if count % 20 == 0: + await asyncio.sleep(0) + return results[::-1] + + async def mam_fill_gap(self, gap: HistoryGap, amount: Optional[int] = None) -> List[BaseMessage]: + """Fill a message gap in an existing tab using MAM. + + :param gap: Object describing the history gap + :returns: list of ui messages to add + """ + tab = self.tab + if amount is None: + amount = HARD_LIMIT + + start = gap.last_timestamp_before_leave + end = gap.first_timestamp_after_join + if start: + start = start + timedelta(seconds=1) + if end: + end = end - timedelta(seconds=1) + try: + return await fetch_history( + tab, + start=start, + end=end, + amount=amount, + ) + except (NoMAMSupportException, MAMQueryException, DiscoInfoException): + return [] + finally: + tab.query_status = False + + async def local_fill_gap(self, gap: HistoryGap, amount: Optional[int] = None) -> List[BaseMessage]: + """Fill a message gap in an existing tab using the local logs. + Mostly useless when not used with the MAMFiller. + + :param gap: Object describing the history gap + :returns: list of ui messages to add + """ + if amount is None: + amount = HARD_LIMIT + await self.wait_mam() + limit = self._get_time_limit() + start = gap.last_timestamp_before_leave + end = gap.first_timestamp_after_join + count = 0 + + results: List[BaseMessage] = [] + filepath = self.logger.get_file_path(self.tab.jid) + for msg in iterate_messages_reverse(filepath): + typ_ = msg.pop('type') + if start and msg['time'] < start: + break + if typ_ == 'message' and (not end or msg['time'] < end): + results.append(make_line_local(self.tab, msg)) + elif msg['time'] < limit and 'set the subject' not in msg['txt']: + break + if len(results) >= amount: + break + count += 1 + if count % 20 == 0: + await asyncio.sleep(0) + return results[::-1] + + async def scroll_requested(self): + """When a scroll up is requested in a chat tab. + + Try to load more history if there are no more messages in the buffer. + """ + tab = self.tab + tw = tab.text_win + + # If position in the tab is < two screen pages, then fetch MAM, so that + # wa keep some prefetched margin. A first page should also be + # prefetched on join if not already available. + total, pos, height = len(tw.built_lines), tw.pos, tw.height + rest = (total - pos) // height + + if rest > 1: + return None + + if self.mam_only: + messages = await self.mam_scroll_requested(height) + else: + messages = await self.local_scroll_requested(height) + if messages: + tab._text_buffer.add_history_messages(messages) + tab.core.refresh_window() + self._done() + + async def local_scroll_requested(self, nb: int) -> List[BaseMessage]: + """Fetch messages locally on scroll up. + + :param nb: Number of messages to fetch + :returns: list of ui messages to add + """ + await self.wait_mam() + tab = self.tab + count = 0 + + first_message = tab._text_buffer.find_first_message() + first_message_time = None + if first_message: + first_message_time = first_message.time - timedelta(microseconds=1) + + results: List[BaseMessage] = [] + filepath = self.logger.get_file_path(self.tab.jid) + for msg in iterate_messages_reverse(filepath): + typ_ = msg.pop('type') + if first_message_time is None or msg['time'] < first_message_time: + if typ_ == 'message': + results.append(make_line_local(self.tab, msg)) + if len(results) >= nb: + break + count += 1 + if count % 20 == 0: + await asyncio.sleep(0) + return results[::-1] + + async def mam_scroll_requested(self, nb: int) -> List[BaseMessage]: + """Fetch messages from MAM on scroll up. + + :param nb: Number of messages to fetch + :returns: list of ui messages to add + """ + tab = self.tab + try: + messages = await fetch_history(tab, amount=nb) + last_message_exists = False + if tab._text_buffer.messages: + last_message = tab._text_buffer.messages[0] + last_message_exists = True + if (not messages and + last_message_exists + and not isinstance(last_message, EndOfArchive)): + time = tab._text_buffer.messages[0].time + messages = [EndOfArchive('End of archive reached', time=time)] + return messages + except NoMAMSupportException: + return [] + except (MAMQueryException, DiscoInfoException): + tab.core.information( + f'An error occured when fetching MAM for {tab.jid}', + 'Error' + ) + return [] + finally: + tab.query_status = False + + async def wait_mam(self) -> None: + """Wait for the MAM history sync before reading the local logs. + + Does nothing apart from blocking. + """ + if self.tab.mam_filler is None: + return + await self.tab.mam_filler.done.wait() + + +class MAMFiller: + """Class that loads messages from MAM history into the local logs. + """ + tab: tabs.ChatTab + logger: Logger + future: asyncio.Future + done: asyncio.Event + limit: int + + def __init__(self, logger: Logger, tab: tabs.ChatTab, limit: int = 2000): + self.tab = tab + self.logger = logger + logger.fd_busy(tab.jid) + self.future = asyncio.create_task(self.fetch_routine()) + self.done = asyncio.Event() + self.limit = limit + self.result = 0 + + def cancel(self) -> None: + """Cancel the routine and signal the end.""" + self.future.cancel() + self.end() + + async def fetch_routine(self) -> None: + """Load logs into the local archive, if possible.""" + filepath = self.logger.get_file_path(self.tab.jid) + log.debug('Fetching logs for %s', self.tab.jid) + try: + last_msg = last_message_in_archive(filepath) + last_msg_time = None + if last_msg: + last_msg_time = last_msg['time'] + timedelta(seconds=1) + try: + messages = await fetch_history( + self.tab, + start=last_msg_time, + amount=self.limit, + ) + log.debug( + 'Fetched %s messages to fill local logs for %s', + len(messages), self.tab.jid, + ) + self.result = len(messages) + except NoMAMSupportException: + log.debug('The entity %s does not support MAM', self.tab.jid) + return + except (DiscoInfoException, MAMQueryException): + log.debug( + 'Failed fetching logs for %s', + self.tab.jid, exc_info=True + ) + return + + def build_message(msg) -> str: + return build_log_message( + msg.nickname, + msg.txt, + msg.time, + prefix='MR', + ) + + logs = ''.join(map(build_message, messages)) + self.logger.log_raw(self.tab.jid, logs, force=True) + finally: + self.end() + + def end(self) -> None: + """End a MAM fill (error or sucess). Remove references and signal on + the Event(). + """ + try: + self.logger.fd_available(self.tab.jid) + except Exception: + log.error('Error when restoring log fd:', exc_info=True) + self.tab.mam_filler = None + self.done.set() diff --git a/poezio/logger.py b/poezio/logger.py index c8ec66d9..29eaad32 100644 --- a/poezio/logger.py +++ b/poezio/logger.py @@ -3,7 +3,7 @@ # 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. +# it under the terms of the GPL-3.0+ license. See the COPYING file. """ The logger module that handles logging of the poezio conversations and roster changes @@ -11,20 +11,21 @@ conversations and roster changes import mmap import re -from typing import List, Dict, Optional, IO, Any +from typing import List, Dict, Optional, IO, Any, Union, Generator from datetime import datetime +from pathlib import Path from poezio import common from poezio.config import config from poezio.xhtml import clean_text -from poezio.theming import dump_tuple, get_theme +from poezio.ui.types import Message, BaseMessage, LoggableTrait +from slixmpp import JID +from poezio.types import TypedDict import logging log = logging.getLogger(__name__) -from poezio.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+) <([^ ]+)> (.*)$') @@ -34,8 +35,13 @@ INFO_LOG_RE = re.compile(r'^MI (\d{4})(\d{2})(\d{2})T' class LogItem: - def __init__(self, year, month, day, hour, minute, second, nb_lines, - message): + time: datetime + nb_lines: int + text: str + + def __init__(self, year: str, month: str, day: str, hour: str, minute: str, + second: str, nb_lines: str, + message: str): self.time = datetime( int(year), int(month), int(day), int(hour), int(minute), int(second)) @@ -49,21 +55,40 @@ class LogInfo(LogItem): class LogMessage(LogItem): - def __init__(self, year, month, day, hour, minute, seconds, nb_lines, nick, - message): + nick: str + + def __init__(self, year: str, month: str, day: str, hour: str, minute: str, + seconds: str, nb_lines: str, nick: str, + message: str): LogItem.__init__(self, year, month, day, hour, minute, seconds, nb_lines, message) self.nick = nick -def parse_log_line(msg: str) -> Optional[LogItem]: - match = re.match(MESSAGE_LOG_RE, msg) +LogDict = TypedDict( + 'LogDict', + { + 'type': str, 'txt': str, 'time': datetime, + 'history': bool, 'nickname': str + }, + total=False, +) + + +def parse_log_line(msg: str, jid: str = '') -> Optional[LogItem]: + """Parse a log line. + + :param msg: The message ligne + :param jid: jid (for error logging) + :returns: The LogItem or None on error + """ + match = MESSAGE_LOG_RE.match(msg) if match: return LogMessage(*match.groups()) - match = re.match(INFO_LOG_RE, msg) + match = INFO_LOG_RE.match(msg) if match: return LogInfo(*match.groups()) - log.debug('Error while parsing "%s"', msg) + log.debug('Error while parsing %s’s logs: “%s”', jid, msg) return None @@ -72,139 +97,175 @@ class Logger: Appends things to files. Error/information/warning logs and also log the conversations to logfiles """ + _roster_logfile: Optional[IO[str]] + log_dir: Path + _fds: Dict[str, IO[str]] + _busy_fds: Dict[str, bool] def __init__(self): - self._roster_logfile = None # Optional[IO[Any]] + self.log_dir = Path() + self._roster_logfile = None # a dict of 'groupchatname': file-object (opened) - self._fds = {} # type: Dict[str, IO[Any]] + self._fds = {} + self._busy_fds = {} + self._buffered_fds = {} def __del__(self): + """Close all fds on exit""" for opened_file in self._fds.values(): if opened_file: try: opened_file.close() - except: # Can't close? too bad + except Exception: # Can't close? too bad pass + try: + self._roster_logfile.close() + except Exception: + pass - def close(self, jid) -> None: - jid = str(jid).replace('/', '\\') - if jid in self._fds: - self._fds[jid].close() + def get_file_path(self, jid: Union[str, JID]) -> Path: + """Return the log path for a specific jid""" + jidstr = str(jid).replace('/', '\\') + return self.log_dir / jidstr + + def fd_busy(self, jid: Union[str, JID]) -> None: + """Signal to the logger that this logfile is busy elsewhere. + And that the messages should be queued to be logged later. + + :param jid: file name + """ + jidstr = str(jid).replace('/', '\\') + self._busy_fds[jidstr] = True + if jidstr not in self._buffered_fds: + self._buffered_fds[jidstr] = [] + + def fd_available(self, jid: Union[str, JID]) -> None: + """Signal to the logger that this logfile is no longer busy. + And write messages to the end. + + :param jid: file name + """ + jidstr = str(jid).replace('/', '\\') + if jidstr in self._busy_fds: + del self._busy_fds[jidstr] + if jidstr in self._buffered_fds: + msgs = ''.join(self._buffered_fds.pop(jidstr)) + if jidstr in self._fds: + self._fds[jidstr].close() + del self._fds[jidstr] + self.log_raw(jid, msgs) + + def close(self, jid: str) -> None: + """Close the log file for a JID.""" + jidstr = str(jid).replace('/', '\\') + if jidstr in self._fds: + self._fds[jidstr].close() log.debug('Log file for %s closed.', jid) - del self._fds[jid] - return None + del self._fds[jidstr] def reload_all(self) -> None: """Close and reload all the file handles (on SIGHUP)""" - for opened_file in self._fds.values(): + not_closed = set() + for key, opened_file in self._fds.items(): if opened_file: - opened_file.close() + try: + opened_file.close() + except Exception: + not_closed.add(key) + if self._roster_logfile: + try: + self._roster_logfile.close() + except Exception: + not_closed.add('roster') log.debug('All log file handles closed') + if not_closed: + log.error('Unable to close log files for: %s', not_closed) for room in self._fds: self._check_and_create_log_dir(room) log.debug('Log handle for %s re-created', room) - return None - def _check_and_create_log_dir(self, room: str, - open_fd: bool = True) -> Optional[IO[Any]]: + def _check_and_create_log_dir(self, jid: Union[str, JID], + open_fd: bool = True) -> Optional[IO[str]]: """ Check that the directory where we want to log the messages exists. if not, create it + + :param jid: JID of the file to open after creating the dir + :param open_fd: if the file should be opened after creating the dir + :returns: the opened fd or None """ - if not config.get_by_tabname('use_log', room): + if not config.get_by_tabname('use_log', JID(jid)): return None + # POSIX filesystems don't support / in filename, so we replace it with a backslash + jid = str(jid).replace('/', '\\') try: - log_dir.mkdir(parents=True, exist_ok=True) - except OSError as e: + self.log_dir.mkdir(parents=True, exist_ok=True) + except OSError: log.error('Unable to create the log dir', exc_info=True) - except: + except Exception: log.error('Unable to create the log dir', exc_info=True) return None if not open_fd: return None - filename = log_dir / room + filename = self.get_file_path(jid) try: fd = filename.open('a', encoding='utf-8') - self._fds[room] = fd + self._fds[jid] = fd return fd except IOError: log.error( 'Unable to open the log file (%s)', filename, exc_info=True) return None - def get_logs(self, jid: str, - nb: int = 10) -> Optional[List[Dict[str, Any]]]: - """ - 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 None - - if not config.get_by_tabname('use_log', jid): - return None - - if nb <= 0: - return None - - self._check_and_create_log_dir(jid, open_fd=False) - - filename = log_dir / jid - try: - fd = filename.open('rb') - except FileNotFoundError: - log.info('Non-existing log file (%s)', filename, exc_info=True) - return None - except OSError: - log.error( - 'Unable to open the log file (%s)', filename, exc_info=True) - return None - if not fd: - return None - - # 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: - lines = _get_lines_from_fd(fd, nb=nb) - except Exception: # file probably empty - log.error( - 'Unable to mmap the log file for (%s)', - filename, - exc_info=True) - return None - return parse_log_lines(lines) - def log_message(self, jid: str, - nick: str, - msg: str, - date: Optional[datetime] = None, - typ: int = 1) -> bool: + msg: Union[BaseMessage, Message]) -> bool: """ - log the message in the appropriate jid's file - type: - 0 = Don’t log - 1 = Message - 2 = Status/whatever + Log the message in the appropriate file + + :param jid: JID of the entity for which to log the message + :param msg: Message to log + :returns: True if no error was encountered """ - if not config.get_by_tabname('use_log', jid): + if not config.get_by_tabname('use_log', JID(jid)): return True - logged_msg = build_log_message(nick, msg, date=date, typ=typ) + if not isinstance(msg, LoggableTrait): + return True + date = msg.time + txt = msg.txt + nick = '' + typ = 'MI' + if isinstance(msg, Message): + nick = msg.nickname or '' + if msg.me: + txt = f'/me {txt}' + typ = 'MR' + logged_msg = build_log_message(nick, txt, date=date, prefix=typ) if not logged_msg: return True - if jid in self._fds.keys(): - fd = self._fds[jid] + return self.log_raw(jid, logged_msg) + + def log_raw(self, jid: Union[str, JID], logged_msg: str, force: bool = False) -> bool: + """Log a raw string. + + :param jid: filename + :param logged_msg: string to log + :param force: Bypass the buffered fd check + :returns: True if no error was encountered + """ + jidstr = str(jid).replace('/', '\\') + if jidstr in self._fds.keys(): + fd = self._fds[jidstr] else: option_fd = self._check_and_create_log_dir(jid) if option_fd is None: return True fd = option_fd - filename = log_dir / jid + filename = self.get_file_path(jid) try: + if not force and self._busy_fds.get(jidstr): + self._buffered_fds[jidstr].append(logged_msg) + return True fd.write(logged_msg) except OSError: log.error( @@ -226,11 +287,15 @@ class Logger: def log_roster_change(self, jid: str, message: str) -> bool: """ Log a roster change + + :param jid: jid to log the change for + :param message: message to log + :returns: True if no error happened """ - if not config.get_by_tabname('use_log', jid): + if not config.get_by_tabname('use_log', JID(jid)): return True self._check_and_create_log_dir('', open_fd=False) - filename = log_dir / 'roster.log' + filename = self.log_dir / 'roster.log' if not self._roster_logfile: try: self._roster_logfile = filename.open('a', encoding='utf-8') @@ -251,7 +316,7 @@ class Logger: for line in lines: self._roster_logfile.write(' %s\n' % line) self._roster_logfile.flush() - except: + except Exception: log.error( 'Unable to write in the log file (%s)', filename, @@ -263,21 +328,19 @@ class Logger: def build_log_message(nick: str, msg: str, date: Optional[datetime] = None, - typ: int = 1) -> str: + prefix: str = 'MI') -> str: """ Create a log message from a nick, a message, optionally a date and type - message types: - 0 = Don’t log - 1 = Message - 2 = Status/whatever - """ - if not typ: - return '' + :param nick: nickname to log + :param msg: text of the message + :param date: date of the message + :param prefix: MI (info) or MR (message) + :returns: The log line(s) + """ msg = clean_text(msg) time = common.get_utc_time() if date is None else common.get_utc_time(date) str_time = time.strftime('%Y%m%dT%H:%M:%SZ') - prefix = 'MR' if typ == 1 else 'MI' lines = msg.split('\n') first_line = lines.pop(0) nb_lines = str(len(lines)).zfill(3) @@ -290,28 +353,62 @@ def build_log_message(nick: str, return logged_msg + ''.join(' %s\n' % line for line in lines) -def _get_lines_from_fd(fd: IO[Any], nb: int = 10) -> List[str]: +def last_message_in_archive(filepath: Path) -> Optional[LogDict]: + """Get the last message from the local archive. + + :param filepath: the log file path """ - Get the last log lines from a fileno + last_msg = None + for msg in iterate_messages_reverse(filepath): + if msg['type'] == 'message': + last_msg = msg + break + return last_msg + + +def iterate_messages_reverse(filepath: Path) -> Generator[LogDict, None, None]: + """Get the latest messages from the log file, one at a time. + + :param fd: the file descriptor """ - with mmap.mmap(fd.fileno(), 0, prot=mmap.PROT_READ) as m: - # start of messages begin with MI or MR, after a \n - pos = m.rfind(b"\nM") + 1 - # number of message found so far - count = 0 - while pos != 0 and count < nb - 1: - count += 1 - pos = m.rfind(b"\nM", 0, pos) + 1 - lines = m[pos:].decode(errors='replace').splitlines() - return lines - - -def parse_log_lines(lines: List[str]) -> List[Dict[str, Any]]: + try: + with open(filepath, 'rb') as fd: + with mmap.mmap(fd.fileno(), 0, prot=mmap.PROT_READ) as m: + # start of messages begin with MI or MR, after a \n + pos = m.rfind(b"\nM") + 1 + if pos != -1: + lines = parse_log_lines( + m[pos:-1].decode(errors='replace').splitlines() + ) + elif m[0:1] == b'M': + # Handle the case of a single message present in the log + # file, hence no newline. + lines = parse_log_lines( + m[:].decode(errors='replace').splitlines() + ) + if lines: + yield lines[0] + while pos > 0: + old_pos = pos + pos = m.rfind(b"\nM", 0, pos) + 1 + lines = parse_log_lines( + m[pos:old_pos].decode(errors='replace').splitlines() + ) + if lines: + yield lines[0] + except (OSError, ValueError): + pass + + +def parse_log_lines(lines: List[str], jid: str = '') -> List[LogDict]: """ Parse raw log lines into poezio log objects + + :param lines: Message lines + :param jid: jid (for error logging) + :return: a list of dicts containing message info """ messages = [] - color = '\x19%s}' % dump_tuple(get_theme().COLOR_LOG_MSG) # now convert that data into actual Message objects idx = 0 @@ -320,22 +417,24 @@ def parse_log_lines(lines: List[str]) -> List[Dict[str, Any]]: idx += 1 log.debug('fail?') continue - log_item = parse_log_line(lines[idx]) + log_item = parse_log_line(lines[idx], jid) idx += 1 if not isinstance(log_item, LogItem): log.debug('wrong log format? %s', log_item) continue message_lines = [] - message = { + message = LogDict({ 'history': True, - 'time': common.get_local_time(log_item.time) - } + 'time': common.get_local_time(log_item.time), + 'type': 'message', + }) size = log_item.nb_lines if isinstance(log_item, LogInfo): - message_lines.append(color + log_item.text) + message_lines.append(log_item.text) + message['type'] = 'info' elif isinstance(log_item, LogMessage): message['nickname'] = log_item.nick - message_lines.append(color + log_item.text) + message_lines.append(log_item.text) while size != 0 and idx < len(lines): message_lines.append(lines[idx][1:]) size -= 1 @@ -345,10 +444,4 @@ def parse_log_lines(lines: List[str]) -> List[Dict[str, Any]]: return messages -def create_logger() -> None: - "Create the global logger object" - global logger - logger = Logger() - - -logger = None # type: Optional[Logger] +logger = Logger() diff --git a/poezio/mam.py b/poezio/mam.py new file mode 100644 index 00000000..7cb1d369 --- /dev/null +++ b/poezio/mam.py @@ -0,0 +1,211 @@ +""" + Query and control an archive of messages stored on a server using + XEP-0313: Message Archive Management(MAM). +""" + +from __future__ import annotations + +import logging +from datetime import datetime, timedelta, timezone +from hashlib import md5 +from typing import ( + Any, + AsyncIterable, + Dict, + List, + Optional, +) + +from slixmpp import JID, Message as SMessage +from slixmpp.exceptions import IqError, IqTimeout +from poezio.theming import get_theme +from poezio import tabs +from poezio import colors +from poezio.common import to_utc +from poezio.ui.types import ( + BaseMessage, + Message, +) + + +log = logging.getLogger(__name__) + +class DiscoInfoException(Exception): pass +class MAMQueryException(Exception): pass +class NoMAMSupportException(Exception): pass + + +def make_line( + tab: tabs.ChatTab, + text: str, + time: datetime, + jid: JID, + identifier: str = '', + nick: str = '' + ) -> Message: + """Adds a textual entry in the TextBuffer""" + + # Convert to local timezone + time = time.replace(tzinfo=timezone.utc).astimezone(tz=None) + time = time.replace(tzinfo=None) + + if isinstance(tab, tabs.MucTab): + nick = jid.resource + user = tab.get_user_by_name(nick) + if user: + color = user.color + else: + theme = get_theme() + if theme.ccg_palette: + fg_color = colors.ccg_text_to_color(theme.ccg_palette, nick) + color = fg_color, -1 + else: + mod = len(theme.LIST_COLOR_NICKNAMES) + nick_pos = int(md5(nick.encode('utf-8')).hexdigest(), 16) % mod + color = theme.LIST_COLOR_NICKNAMES[nick_pos] + else: + if jid.bare == tab.core.xmpp.boundjid.bare: + if not nick: + nick = tab.core.own_nick + color = get_theme().COLOR_OWN_NICK + else: + color = get_theme().COLOR_REMOTE_USER + if not nick: + nick = tab.get_nick() + return Message( + txt=text, + identifier=identifier, + time=time, + nickname=nick, + nick_color=color, + history=True, + user=None, + ) + +async def get_mam_iterator( + core, + groupchat: bool, + remote_jid: JID, + amount: int, + reverse: bool = True, + start: Optional[str] = None, + end: Optional[str] = None, + before: Optional[str] = None, + ) -> AsyncIterable[SMessage]: + """Get an async iterator for this mam query""" + try: + query_jid = remote_jid if groupchat else JID(core.xmpp.boundjid.bare) + iq = await core.xmpp.plugin['xep_0030'].get_info(jid=query_jid) + except (IqError, IqTimeout): + raise DiscoInfoException() + if 'urn:xmpp:mam:2' not in iq['disco_info'].get_features(): + raise NoMAMSupportException() + + args: Dict[str, Any] = { + 'iterator': True, + 'reverse': reverse, + } + + if groupchat: + args['jid'] = remote_jid + else: + args['with_jid'] = remote_jid + + if amount > 0: + args['rsm'] = {'max': amount} + args['start'] = start + args['end'] = end + return core.xmpp['xep_0313'].retrieve(**args) + + +def _parse_message(msg: SMessage) -> Dict: + """Parse info inside a MAM forwarded message""" + forwarded = msg['mam_result']['forwarded'] + message = forwarded['stanza'] + return { + 'time': forwarded['delay']['stamp'], + 'jid': message['from'], + 'text': message['body'], + 'identifier': message['origin-id'] + } + + +def _ignore_private_message(stanza: SMessage, filter_jid: Optional[JID]) -> bool: + """Returns True if a MUC-PM should be ignored, as prosody returns + all PMs within the same room. + """ + if filter_jid is None: + return False + sent = stanza['from'].bare != filter_jid.bare + if sent and stanza['to'].full != filter_jid.full: + return True + elif not sent and stanza['from'].full != filter_jid.full: + return True + return False + + +async def retrieve_messages(tab: tabs.ChatTab, + results: AsyncIterable[SMessage], + amount: int = 100) -> List[BaseMessage]: + """Run the MAM query and put messages in order""" + msg_count = 0 + msgs = [] + to_add = [] + tab_is_private = isinstance(tab, tabs.PrivateTab) + filter_jid = None + if tab_is_private: + filter_jid = tab.jid + try: + async for rsm in results: + for msg in rsm['mam']['results']: + stanza = msg['mam_result']['forwarded']['stanza'] + if stanza.xml.find('{%s}%s' % ('jabber:client', 'body')) is not None: + if _ignore_private_message(stanza, filter_jid): + continue + args = _parse_message(msg) + msgs.append(make_line(tab, **args)) + for msg in reversed(msgs): + to_add.append(msg) + msg_count += 1 + if msg_count == amount: + to_add.reverse() + return to_add + msgs = [] + to_add.reverse() + return to_add + except (IqError, IqTimeout) as exc: + log.debug('Unable to complete MAM query: %s', exc, exc_info=True) + raise MAMQueryException('Query interrupted') + + +async def fetch_history(tab: tabs.ChatTab, + start: Optional[datetime] = None, + end: Optional[datetime] = None, + amount: int = 100) -> List[BaseMessage]: + remote_jid = tab.jid + if not end: + for msg in tab._text_buffer.messages: + if isinstance(msg, Message): + end = msg.time + end -= timedelta(microseconds=1) + break + if end is None: + end = datetime.now() + end = to_utc(end) + end_str = datetime.strftime(end, '%Y-%m-%dT%H:%M:%SZ') + + start_str = None + if start is not None: + start = to_utc(start) + start_str = datetime.strftime(start, '%Y-%m-%dT%H:%M:%SZ') + + mam_iterator = await get_mam_iterator( + core=tab.core, + groupchat=isinstance(tab, tabs.MucTab), + remote_jid=remote_jid, + amount=amount, + end=end_str, + start=start_str, + reverse=True, + ) + return await retrieve_messages(tab, mam_iterator, amount) diff --git a/poezio/multiuserchat.py b/poezio/multiuserchat.py index 73a802b2..3278e1bd 100644 --- a/poezio/multiuserchat.py +++ b/poezio/multiuserchat.py @@ -3,76 +3,51 @@ # 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. +# it under the terms of the GPL-3.0+ 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 __future__ import annotations -from poezio.common import safeJID -from slixmpp.exceptions import IqError, IqTimeout -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') +import asyncio +from xml.etree import ElementTree as ET +from typing import ( + Optional, + Union, + TYPE_CHECKING, +) - iq.send(callback=callback) - return True +from slixmpp import ( + JID, + ClientXMPP, + Iq, + Presence, +) - -def send_private_message(xmpp, jid, line): - """ - Send a private message - """ - jid = safeJID(jid) - xmpp.send_message(mto=jid, mbody=line, mtype='chat') +import logging +log = logging.getLogger(__name__) -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') +if TYPE_CHECKING: + from poezio.core.core import Core + from poezio.tabs import MucTab -def change_show(xmpp, jid, own_nick, show, status): +def change_show( + xmpp: ClientXMPP, + jid: JID, + own_nick: str, + show: str, + status: Optional[str] +) -> None: """ Change our 'Show' """ - jid = safeJID(jid) - pres = xmpp.make_presence(pto='%s/%s' % (jid, own_nick)) + jid = JID(jid) + pres: Presence = 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: @@ -80,60 +55,75 @@ def change_show(xmpp, jid, own_nick, show, 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): +def change_nick( + core: Core, + jid: Union[JID, str], + nick: str, + status: Optional[str] = None, + show: Optional[str] = None +) -> 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))) + presence: Presence = xmpp.make_presence( + pshow=show, pstatus=status, pto=JID('%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): +def join_groupchat( + core: Core, + jid: JID, + nick: str, + passwd: str = '', + status: Optional[str] = None, + show: Optional[str] = None, + seconds: Optional[int] = None, + tab: Optional['MucTab'] = None +) -> None: xmpp = core.xmpp - stanza = xmpp.make_presence( + stanza: Presence = 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'].our_nicks[jid] = to.resource - - -def leave_groupchat(xmpp, jid, own_nick, msg): + + def on_disco(iq: Iq) -> None: + if ('urn:xmpp:mam:2' in iq['disco_info'].get_features() + or (tab and tab._text_buffer.last_message)): + history = ET.Element('{http://jabber.org/protocol/muc}history') + history.attrib['seconds'] = str(0) + x.append(history) + else: + 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'].our_nicks[jid] = to.resource + + asyncio.create_task( + xmpp.plugin['xep_0030'].get_info(jid=jid, callback=on_disco) + ) + + +def leave_groupchat( + xmpp: ClientXMPP, + jid: JID, + own_nick: str, + msg: str +) -> None: """ Leave the groupchat """ - jid = safeJID(jid) + jid = JID(jid) try: xmpp.plugin['xep_0045'].leave_muc(jid, own_nick, msg) except KeyError: @@ -141,91 +131,3 @@ def leave_groupchat(xmpp, jid, own_nick, msg): "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 (IqError, IqTimeout) 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'].set_affiliation( - str(muc_jid), - str(jid) if jid else None, nick, affiliation) - except: - log.debug('Error setting the affiliation: %s', exc_info=True) - 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['type'] = 'submit' - query.append(form.xml) - iq.append(query) - iq.send() diff --git a/poezio/pep.py b/poezio/pep.py deleted file mode 100644 index 52cc4cd5..00000000 --- a/poezio/pep.py +++ /dev/null @@ -1,207 +0,0 @@ -""" -Collection of mappings for PEP moods/activities -extracted directly from the XEP -""" - -from typing import Dict - -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' -} # type: Dict[str, str] - -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', - } -} # type: Dict[str, Dict[str, str]] diff --git a/poezio/plugin.py b/poezio/plugin.py index 7e67d09c..f38e47e2 100644 --- a/poezio/plugin.py +++ b/poezio/plugin.py @@ -3,6 +3,9 @@ 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) """ + +from typing import Any, Dict, Set, Optional +from asyncio import iscoroutinefunction from functools import partial from configparser import RawConfigParser from poezio.timed_events import TimedEvent, DelayedEvent @@ -23,6 +26,7 @@ class PluginConfig(config.Config): def __init__(self, filename, module_name, default=None): config.Config.__init__(self, filename, default=default) self.module_name = module_name + self.default_section = module_name self.read() def get(self, option, default=None, section=None): @@ -42,7 +46,7 @@ class PluginConfig(config.Config): def read(self): """Read the config file""" - RawConfigParser.read(self, str(self.file_name)) + RawConfigParser.read(self.configparser, str(self.file_name)) if not self.has_section(self.module_name): self.add_section(self.module_name) @@ -61,7 +65,7 @@ class PluginConfig(config.Config): """Write the config to the disk""" try: with self.file_name.open('w') as fp: - RawConfigParser.write(self, fp) + RawConfigParser.write(self.configparser, fp) return True except IOError: return False @@ -74,9 +78,12 @@ class SafetyMetaclass(type): @staticmethod def safe_func(f): def helper(*args, **kwargs): + passthrough = kwargs.pop('passthrough', False) try: return f(*args, **kwargs) except: + if passthrough: + raise if inspect.stack()[1][1] == inspect.getfile(f): raise elif SafetyMetaclass.core: @@ -84,9 +91,25 @@ class SafetyMetaclass(type): SafetyMetaclass.core.information(traceback.format_exc(), 'Error') return None - + async def async_helper(*args, **kwargs): + passthrough = kwargs.pop('passthrough', False) + try: + return await f(*args, **kwargs) + except: + if passthrough: + raise + 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(), + 'Error') + return None + if iscoroutinefunction(f): + return async_helper return helper + def __new__(meta, name, bases, class_dict): for k, v in class_dict.items(): if inspect.isfunction(v): @@ -379,28 +402,35 @@ class BasePlugin(object, metaclass=SafetyMetaclass): Class that all plugins derive from. """ - default_config = None + # Internal use only + _unloading = False + + default_config: Optional[Dict[Any, Any]] = None + dependencies: Set[str] = set() + # This dict will get populated when the plugin is initialized + refs: Dict[str, Any] = {} - def __init__(self, plugin_api, core, plugins_conf_dir): + def __init__(self, name, plugin_api, core, plugins_conf_dir): + self.__name = name self.core = core # More hack; luckily we'll never have more than one core object SafetyMetaclass.core = core - conf = plugins_conf_dir / (self.__module__ + '.cfg') + conf = plugins_conf_dir / (self.__name + '.cfg') try: self.config = PluginConfig( - conf, self.__module__, default=self.default_config) + conf, self.__name, default=self.default_config) except Exception: log.debug('Error while creating the plugin config', exc_info=True) - self.config = PluginConfig(conf, self.__module__) + self.config = PluginConfig(conf, self.__name) self._api = plugin_api[self.name] self.init() @property - def name(self): + def name(self) -> str: """ Get the name (module name) of the plugin. """ - return self.__module__ + return self.__name @property def api(self): @@ -501,12 +531,12 @@ class BasePlugin(object, metaclass=SafetyMetaclass): """ return self.api.del_tab_command(tab_type, name) - def add_event_handler(self, event_name, handler, position=0): + def add_event_handler(self, event_name, handler, *args, **kwargs): """ 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) + return self.api.add_event_handler(event_name, handler, *args, **kwargs) def del_event_handler(self, event_name, handler): """ diff --git a/poezio/plugin_e2ee.py b/poezio/plugin_e2ee.py new file mode 100644 index 00000000..49f7b067 --- /dev/null +++ b/poezio/plugin_e2ee.py @@ -0,0 +1,685 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# vim:fenc=utf-8 et ts=4 sts=4 sw=4 +# +# Copyright © 2019 Maxime “pep” Buquet <pep@bouah.net> +# +# Distributed under terms of the GPL-3.0+ license. See COPYING file. + +""" + Interface for E2EE (End-to-end Encryption) plugins. +""" + +from typing import ( + Callable, + Dict, + List, + Optional, + Union, + Tuple, + Set, + Type, +) + +from slixmpp import InvalidJID, JID, Message +from slixmpp.xmlstream import StanzaBase +from slixmpp.xmlstream.handler import CoroutineCallback +from slixmpp.xmlstream.matcher import MatchXPath +from poezio.tabs import ( + ChatTab, + ConversationTab, + DynamicConversationTab, + MucTab, + PrivateTab, + RosterInfoTab, + StaticConversationTab, +) +from poezio.plugin import BasePlugin +from poezio.theming import Theme, get_theme, dump_tuple +from poezio.config import config +from poezio.decorators import command_args_parser + +import asyncio +from asyncio import iscoroutinefunction + +import logging +log = logging.getLogger(__name__) + + +ChatTabs = Union[ + MucTab, + DynamicConversationTab, + StaticConversationTab, + PrivateTab, +] + +EME_NS = 'urn:xmpp:eme:0' +EME_TAG = 'encryption' + +JCLIENT_NS = 'jabber:client' +HINTS_NS = 'urn:xmpp:hints' + +class NothingToEncrypt(Exception): + """ + Exception to raise inside the _encrypt filter on stanzas that do not need + to be processed. + """ + + +class E2EEPlugin(BasePlugin): + """Interface for E2EE plugins. + + This is a wrapper built on top of BasePlugin. It provides a base for + End-to-end Encryption mechanisms in poezio. + + Plugin developers are excepted to implement the `decrypt` and + `encrypt` function, provide an encryption name (and/or short name), + and an eme namespace. + + Once loaded, the plugin will attempt to decrypt any message that + contains an EME message that matches the one set. + + The plugin will also register a command (using the short name) to + enable encryption per tab. It is only possible to have one encryption + mechanism per tab, even if multiple e2ee plugins are loaded. + + The encryption status will be displayed in the status bar, using the + plugin short name, alongside the JID, nickname etc. + """ + + #: Specifies that the encryption mechanism does more than encrypting + #: `<body/>`. + stanza_encryption = False + + #: Whitelist applied to messages when `stanza_encryption` is `False`. + tag_whitelist = [ + (JCLIENT_NS, 'body'), + (EME_NS, EME_TAG), + (HINTS_NS, 'store'), + (HINTS_NS, 'no-copy'), + (HINTS_NS, 'no-store'), + (HINTS_NS, 'no-permanent-store'), + # TODO: Add other encryption mechanisms tags here + ] + + #: Replaces body with `eme <https://xmpp.org/extensions/xep-0380.html>`_ + #: if set. Should be suitable for most plugins except those using + #: `<body/>` directly as their encryption container, like OTR, or the + #: example base64 plugin in poezio. + replace_body_with_eme = True + + #: Encryption name, used in command descriptions, and logs. At least one + #: of `encryption_name` and `encryption_short_name` must be set. + encryption_name: Optional[str] = None + + #: Encryption short name, used as command name, and also to display + #: encryption status in a tab. At least one of `encryption_name` and + #: `encryption_short_name` must be set. + encryption_short_name: Optional[str] = None + + #: Required. https://xmpp.org/extensions/xep-0380.html. + eme_ns: Optional[str] = None + + #: Used to figure out what messages to attempt decryption for. Also used + #: in combination with `tag_whitelist` to avoid removing encrypted tags + #: before sending. If multiple tags are present, a handler will be + #: registered for each invididual tag/ns pair under <message/>, as opposed + #: to a single handler for all tags combined. + encrypted_tags: Optional[List[Tuple[str, str]]] = None + + # Static map, to be able to limit to one encryption mechanism per tab at a + # time + _enabled_tabs: Dict[JID, Callable] = {} + + # Tabs that support this encryption mechanism + supported_tab_types: Tuple[Type[ChatTab], ...] = tuple() + + # States for each remote entity + trust_states: Dict[str, Set[str]] = {'accepted': set(), 'rejected': set()} + + def init(self): + self._all_trust_states = self.trust_states['accepted'].union( + self.trust_states['rejected'] + ) + if self.encryption_name is None and self.encryption_short_name is None: + raise NotImplementedError + + if self.eme_ns is None: + raise NotImplementedError + + if self.encryption_name is None: + self.encryption_name = self.encryption_short_name + if self.encryption_short_name is None: + self.encryption_short_name = self.encryption_name + + if not self.supported_tab_types: + raise NotImplementedError + + # Ensure decryption is done before everything, so that other handlers + # don't have to know about the encryption mechanism. + self.api.add_event_handler('muc_msg', self._decrypt_wrapper, priority=0) + self.api.add_event_handler('conversation_msg', self._decrypt_wrapper, priority=0) + self.api.add_event_handler('private_msg', self._decrypt_wrapper, priority=0) + + # Register a handler for each invididual tag/ns pair in encrypted_tags + # as well. as _msg handlers only include messages with a <body/>. + if self.encrypted_tags is not None: + default_ns = self.core.xmpp.default_ns + for i, (namespace, tag) in enumerate(self.encrypted_tags): + self.core.xmpp.register_handler(CoroutineCallback(f'EncryptedTag{i}', + MatchXPath(f'{{{default_ns}}}message/{{{namespace}}}{tag}'), + self._decrypt_encryptedtag, + )) + + # Ensure encryption is done after everything, so that whatever can be + # encrypted is encrypted, and no plain element slips in. + # Using a stream filter might be a bit too much, but at least we're + # sure poezio is not sneaking anything past us. + self.core.xmpp.add_filter('out', self._encrypt_wrapper) + + for tab_t in self.supported_tab_types: + self.api.add_tab_command( + tab_t, + self.encryption_short_name, + self._toggle_tab, + usage='', + short='Toggle {} encryption for tab.'.format(self.encryption_name), + help='Toggle automatic {} encryption for tab.'.format(self.encryption_name), + ) + + trust_msg = 'Set {name} state to {state} for this fingerprint on this JID.' + for state in self._all_trust_states: + for tab_t in self.supported_tab_types: + self.api.add_tab_command( + tab_t, + self.encryption_short_name + '_' + state, + lambda args: self.__command_set_state_local(args, state), + usage='<fingerprint>', + short=trust_msg.format(name=self.encryption_short_name, state=state), + help=trust_msg.format(name=self.encryption_short_name, state=state), + ) + self.api.add_command( + self.encryption_short_name + '_' + state, + lambda args: self.__command_set_state_global(args, state), + usage='<JID> <fingerprint>', + short=trust_msg.format(name=self.encryption_short_name, state=state), + help=trust_msg.format(name=self.encryption_short_name, state=state), + ) + + self.api.add_command( + self.encryption_short_name + '_fingerprint', + self._command_show_fingerprints, + usage='[jid]', + short=f'Show {self.encryption_short_name} fingerprint(s) for a JID.', + help=f'Show {self.encryption_short_name} fingerprint(s) for a JID.', + ) + + ConversationTab.add_information_element( + self.encryption_short_name, + self._display_encryption_status, + ) + MucTab.add_information_element( + self.encryption_short_name, + self._display_encryption_status, + ) + PrivateTab.add_information_element( + self.encryption_short_name, + self._display_encryption_status, + ) + + self.__load_encrypted_states() + + def __load_encrypted_states(self) -> None: + """Load previously stored encryption states for jids.""" + for section in config.sections(): + value = config.getstr('encryption', section=section) + if value and value == self.encryption_short_name: + section_jid = JID(section) + self._enabled_tabs[section_jid] = self.encrypt + + def cleanup(self): + ConversationTab.remove_information_element(self.encryption_short_name) + MucTab.remove_information_element(self.encryption_short_name) + PrivateTab.remove_information_element(self.encryption_short_name) + + def _display_encryption_status(self, jid_s: str) -> str: + """ + Return information to display in the infobar if encryption is + enabled for the JID. + """ + + try: + jid = JID(jid_s) + except InvalidJID: + return "" + + if self._encryption_enabled(jid) and self.encryption_short_name: + return " " + self.encryption_short_name + return "" + + def _toggle_tab(self, _input: str) -> None: + tab = self.api.current_tab() + jid: JID = tab.jid + + if self._encryption_enabled(jid): + del self._enabled_tabs[jid] + tab.e2e_encryption = None + config.remove_and_save('encryption', section=jid) + self.api.information( + f'{self.encryption_name} encryption disabled for {jid}', + 'Info', + ) + elif self.encryption_short_name: + self._enabled_tabs[jid] = self.encrypt + tab.e2e_encryption = self.encryption_name + config.set_and_save('encryption', self.encryption_short_name, section=jid) + self.api.information( + f'{self.encryption_name} encryption enabled for {jid}', + 'Info', + ) + + @staticmethod + def format_fingerprint(fingerprint: str, own: bool, theme: Theme) -> str: + return fingerprint + + async def _show_fingerprints(self, jid: JID) -> None: + """Display encryption fingerprints for a JID.""" + theme = get_theme() + fprs = await self.get_fingerprints(jid) + if len(fprs) == 1: + fp, own = fprs[0] + fingerprint = self.format_fingerprint(fp, own, theme) + self.api.information( + f'Fingerprint for {jid}:\n{fingerprint}', + 'Info', + ) + elif fprs: + fmt_fprs = map(lambda fp: self.format_fingerprint(fp[0], fp[1], theme), fprs) + self.api.information( + 'Fingerprints for %s:\n%s' % (jid, '\n\n'.join(fmt_fprs)), + 'Info', + ) + else: + self.api.information( + f'{jid}: No fingerprints to display', + 'Info', + ) + + @command_args_parser.quoted(0, 1) + def _command_show_fingerprints(self, args: List[str]) -> None: + tab = self.api.current_tab() + if not args and isinstance(tab, self.supported_tab_types): + jid = tab.jid + if isinstance(tab, MucTab): + jid = self.core.xmpp.boundjid.bare + elif not args and isinstance(tab, RosterInfoTab): + # Allow running the command without arguments in roster tab + jid = self.core.xmpp.boundjid.bare + elif args: + jid = args[0] + else: + shortname = self.encryption_short_name + self.api.information( + f'{shortname}_fingerprint: Couldn\'t deduce JID from context', + 'Error', + ) + return None + asyncio.create_task(self._show_fingerprints(JID(jid))) + + @command_args_parser.quoted(2) + def __command_set_state_global(self, args, state='') -> None: + if not args: + self.api.information( + 'No fingerprint provided to the command..', + 'Error', + ) + return + jid, fpr = args + if state not in self._all_trust_states: + shortname = self.encryption_short_name + self.api.information( + f'Unknown state for plugin {shortname}: {state}', + 'Error' + ) + return + self.store_trust(jid, state, fpr) + + @command_args_parser.quoted(1) + def __command_set_state_local(self, args, state='') -> None: + if isinstance(self.api.current_tab(), MucTab): + self.api.information( + 'You can only trust each participant of a MUC individually.', + 'Info', + ) + return + jid = self.api.current_tab().jid + if not args: + self.api.information( + 'No fingerprint provided to the command..', + 'Error', + ) + return + fpr = args[0] + if state not in self._all_trust_states: + shortname = self.encryption_short_name + self.api.information( + f'Unknown state for plugin {shortname}: {state}', + 'Error', + ) + return + self.store_trust(jid, state, fpr) + + def _encryption_enabled(self, jid: JID) -> bool: + return self._enabled_tabs.get(jid) == self.encrypt + + async def _encrypt_wrapper(self, stanza: StanzaBase) -> Optional[StanzaBase]: + """ + Wrapper around _encrypt() to handle errors and display the message after encryption. + """ + try: + # pylint: disable=unexpected-keyword-arg + result = await self._encrypt(stanza, passthrough=True) + except NothingToEncrypt: + return stanza + except Exception as exc: + jid = stanza['from'] + tab = self.core.tabs.by_name_and_class(jid, ChatTab) + msg = ' \n\x19%s}Could not decrypt message: %s' % ( + dump_tuple(get_theme().COLOR_CHAR_NACK), + exc, + ) + # XXX: check before commit. Do we not nack in MUCs? + if tab and not isinstance(tab, MucTab): + tab.nack_message(msg, stanza['id'], stanza['to']) + # TODO: display exceptions to the user properly + log.error('Exception in encrypt:', exc_info=True) + return None + return result + + async def _decrypt_wrapper(self, stanza: Message, tab: Optional[ChatTabs]) -> None: + """ + Wrapper around _decrypt() to handle errors and display the message after encryption. + """ + try: + # pylint: disable=unexpected-keyword-arg + await self._decrypt(stanza, tab, passthrough=True) + except Exception as exc: + jid = stanza['to'] + tab = self.core.tabs.by_name_and_class(jid, ChatTab) + msg = ' \n\x19%s}Could not send message: %s' % ( + dump_tuple(get_theme().COLOR_CHAR_NACK), + exc, + ) + # XXX: check before commit. Do we not nack in MUCs? + if tab and not isinstance(tab, MucTab): + tab.nack_message(msg, stanza['id'], stanza['from']) + # TODO: display exceptions to the user properly + log.error('Exception in decrypt:', exc_info=True) + return None + return None + + async def _decrypt_encryptedtag(self, stanza: Message) -> None: + """ + Handler to decrypt encrypted_tags elements that are matched separately + from other messages because the default 'message' handler that we use + only matches messages containing a <body/>. + """ + # If the message contains a body, it will already be handled by the + # other handler. If not, pass it to the handler. + if stanza.xml.find(f'{{{self.core.xmpp.default_ns}}}body') is not None: + return None + + mfrom = stanza['from'] + + # Find what tab this message corresponds to. + if stanza['type'] == 'groupchat': # MUC + tab = self.core.tabs.by_name_and_class( + name=mfrom.bare, cls=MucTab, + ) + elif self.core.handler.is_known_muc_pm(stanza, mfrom): # MUC-PM + tab = self.core.tabs.by_name_and_class( + name=mfrom.full, cls=PrivateTab, + ) + else: # 1:1 + tab = self.core.get_conversation_by_jid( + jid=JID(mfrom.bare), + create=False, + fallback_barejid=True, + ) + log.debug('Found tab %r for encrypted message', tab) + await self._decrypt_wrapper(stanza, tab) + + async def _decrypt(self, message: Message, tab: Optional[ChatTabs], passthrough: bool = True) -> None: + + has_eme: bool = False + if message.xml.find(f'{{{EME_NS}}}{EME_TAG}') is not None and \ + message['eme']['namespace'] == self.eme_ns: + has_eme = True + + has_encrypted_tag: bool = False + if not has_eme and self.encrypted_tags is not None: + tmp: bool = True + for (namespace, tag) in self.encrypted_tags: + tmp = tmp and message.xml.find(f'{{{namespace}}}{tag}') is not None + has_encrypted_tag = tmp + + if not has_eme and not has_encrypted_tag: + return None + + log.debug('Received %s message: %r', self.encryption_name, message['body']) + + # Get the original JID of the sender. The JID might be None if it + # comes from a semi-anonymous MUC for example. Some plugins might be + # fine with this so let them handle it. + jid = message['from'] + + muctab: Optional[MucTab] = None + if isinstance(tab, PrivateTab): + muctab = tab.parent_muc + jid = None + + if muctab is not None or isinstance(tab, MucTab): + if muctab is None: + muctab = tab # type: ignore + nick = message['from'].resource + user = muctab.get_user_by_name(nick) # type: ignore + if user is not None: + jid = user.jid or None + + # Call the enabled encrypt method + func = self.decrypt + if iscoroutinefunction(func): + # pylint: disable=unexpected-keyword-arg + await func(message, jid, tab, passthrough=True) # type: ignore + else: + # pylint: disable=unexpected-keyword-arg + func(message, jid, tab) # type: ignore + + log.debug('Decrypted %s message: %r', self.encryption_name, message['body']) + return None + + async def _encrypt(self, stanza: StanzaBase, passthrough: bool = True) -> Optional[StanzaBase]: + # TODO: Let through messages that contain elements that don't need to + # be encrypted even in an encrypted context, such as MUC mediated + # invites, etc. + # What to do when they're mixed with other elements? It probably + # depends on the element. Maybe they can be mixed with + # `self.tag_whitelist` that are already assumed to be sent as plain by + # the E2EE plugin. + # They might not be accompanied by a <body/> most of the time, nor by + # an encrypted tag. + + if not isinstance(stanza, Message) or stanza['type'] not in ('normal', 'chat', 'groupchat'): + raise NothingToEncrypt() + message = stanza + + + # Is this message already encrypted? Do we need to do all these + # checks? Such as an OMEMO heartbeat. + has_encrypted_tag: bool = False + if self.encrypted_tags is not None: + tmp: bool = True + for (namespace, tag) in self.encrypted_tags: + tmp = tmp and message.xml.find(f'{{{namespace}}}{tag}') is not None + has_encrypted_tag = tmp + + if has_encrypted_tag: + log.debug('Message already contains encrypted tags.') + raise NothingToEncrypt() + + # Find who to encrypt to. If in a groupchat this can be multiple JIDs. + # It is possible that we are not able to find a jid (e.g., semi-anon + # MUCs). Let the plugin decide what to do with this information. + jids: Optional[List[JID]] = [message['to']] + tab = self.core.tabs.by_jid(message['to']) + if tab is None and message['to'].resource: + # Redo the search with the bare JID + tab = self.core.tabs.by_jid(message['to'].bare) + + if tab is None: # Possible message sent directly by the e2ee lib? + log.debug( + 'A message we do not have a tab for ' + 'is being sent to \'%s\'. \n%r.', + message['to'], + message, + ) + + parent = None + if isinstance(tab, PrivateTab): + parent = tab.parent_muc + nick = tab.jid.resource + jids = None + + for user in parent.users: + if user.nick == nick: + jids = user.jid or None + break + + if isinstance(tab, MucTab): + jids = [] + for user in tab.users: + # If the JID of a user is None, assume all others are None and + # we are in a (at least) semi-anon room. TODO: Really check if + # the room is semi-anon. Currently a moderator of a semi-anon + # room will possibly encrypt to everybody, leaking their + # public key/identity, and they wouldn't be able to decrypt it + # anyway if they don't know the moderator's JID. + # TODO: Change MUC to give easier access to this information. + if user.jid is None: + jids = None + break + # If we encrypt to all of these JIDs is up to the plugin, we + # just tell it who is in the room. + # XXX: user.jid shouldn't be empty. That's a MucTab/slixmpp + # bug. + if user.jid.bare: + jids.append(user.jid) + + if tab and not self._encryption_enabled(tab.jid): + raise NothingToEncrypt() + + log.debug('Sending %s message', self.encryption_name) + + has_body = message.xml.find('{%s}%s' % (JCLIENT_NS, 'body')) is not None + + if not self._encryption_enabled(tab.jid): + raise NothingToEncrypt() + + # Drop all messages that don't contain a body if the plugin doesn't do + # Stanza Encryption + if not self.stanza_encryption and not has_body: + log.debug( + '%s plugin: Dropping message as it contains no body, and ' + 'doesn\'t do stanza encryption', + self.encryption_name, + ) + return None + + # Call the enabled encrypt method + func = self._enabled_tabs[tab.jid] + if iscoroutinefunction(func): + # pylint: disable=unexpected-keyword-arg + await func(message, jids, tab, passthrough=True) + else: + # pylint: disable=unexpected-keyword-arg + func(message, jids, tab, passthrough=True) + + if has_body: + # Only add EME tag if the message has a body. + # Per discussion in jdev@: + # The receiving client needs to know the message contains + # meaningful information or not to display notifications to the + # user, and not display anything when it's e.g., a chatstate. + # This does leak the fact that the encrypted payload contains a + # message. + message['eme']['namespace'] = self.eme_ns + message['eme']['name'] = self.encryption_name + + if self.replace_body_with_eme: + self.core.xmpp['xep_0380'].replace_body_with_eme(message) + + # Filter stanza with the whitelist. Plugins doing stanza encryption + # will have to include these in their encrypted container beforehand. + whitelist = self.tag_whitelist + if self.encrypted_tags is not None: + whitelist += self.encrypted_tags + + tag_whitelist = {f'{{{ns}}}{tag}' for (ns, tag) in whitelist} + + for elem in message.xml[:]: + if elem.tag not in tag_whitelist: + message.xml.remove(elem) + + log.debug('Encrypted %s message', self.encryption_name) + return message + + def store_trust(self, jid: JID, state: str, fingerprint: str) -> None: + """Store trust for a fingerprint and a jid.""" + option_name = f'{self.encryption_short_name}:{fingerprint}' + config.silent_set(option=option_name, value=state, section=jid) + + def fetch_trust(self, jid: JID, fingerprint: str) -> str: + """Fetch trust of a fingerprint and a jid.""" + option_name = f'{self.encryption_short_name}:{fingerprint}' + return config.getstr(option=option_name, section=jid) + + async def decrypt(self, message: Message, jid: Optional[JID], tab: Optional[ChatTab]): + """Decryption method + + This is a method the plugin must implement. It is expected that this + method will edit the received message and return nothing. + + :param message: Message to be decrypted. + :param jid: Real Jid of the sender if available. We might be + talking through a semi-anonymous MUC where real JIDs are + not available. + :param tab: Tab the message is coming from. + + :returns: None + """ + + raise NotImplementedError + + async def encrypt(self, message: Message, jids: Optional[List[JID]], tab: ChatTabs): + """Encryption method + + This is a method the plugin must implement. It is expected that this + method will edit the received message and return nothing. + + :param message: Message to be encrypted. + :param jids: Real Jids of all possible recipients. + :param tab: Tab the message is going to. + + :returns: None + """ + + raise NotImplementedError + + async def get_fingerprints(self, jid: JID) -> List[Tuple[str, bool]]: + """Show fingerprint(s) for this encryption method and JID. + + To overload in plugins. + + :returns: A list of fingerprints to display + """ + return [] diff --git a/poezio/plugin_manager.py b/poezio/plugin_manager.py index 89849747..17673a9e 100644 --- a/poezio/plugin_manager.py +++ b/poezio/plugin_manager.py @@ -5,10 +5,13 @@ the API together. Defines also a bunch of variables related to the plugin env. """ +import logging import os -from os import path +from typing import Dict, Set +from importlib import import_module, machinery from pathlib import Path -import logging +from os import path +import pkg_resources from poezio import tabs, xdg from poezio.core.structs import Command, Completion @@ -25,6 +28,8 @@ class PluginManager: And keeps track of everything the plugin has done through the API. """ + rdeps: Dict[str, Set[str]] = {} + def __init__(self, core): self.core = core # module name -> module object @@ -44,7 +49,6 @@ class PluginManager: self.tab_keys = {} self.roster_elements = {} - from importlib import machinery self.finder = machinery.PathFinder() self.initial_set_plugins_dir() @@ -57,21 +61,56 @@ class PluginManager: for plugin in set(self.plugins.keys()): self.unload(plugin, notify=False) - def load(self, name, notify=True): + def set_rdeps(self, name): + """ + Runs through plugin dependencies to build the reverse dependencies table. + """ + + if name not in self.rdeps: + self.rdeps[name] = set() + for dep in self.plugins[name].dependencies: + if dep not in self.rdeps: + self.rdeps[dep] = {name} + else: + self.rdeps[dep].add(name) + + def load(self, name: str, notify=True, unload_first=True): """ Load a plugin. """ + if not unload_first and name in self.plugins: + return None if name in self.plugins: self.unload(name) try: module = None loader = self.finder.find_module(name, self.load_path) - if not loader: + if loader: + log.debug('Found candidate loader for plugin %s: %r', name, loader) + module = loader.load_module() + if module is None: + log.debug('Failed to load plugin %s from loader', name) + else: + try: + module = import_module('poezio_plugins.%s' % name) + except ModuleNotFoundError: + pass + for entry in pkg_resources.iter_entry_points('poezio_plugins'): + if entry.name == name: + log.debug('Found candidate entry for plugin %s: %r', name, entry) + try: + module = entry.load() + except Exception as exn: + log.debug('Failed to import plugin: %s\n%r', name, + exn, exc_info=True) + finally: + break + if not module: self.core.information('Could not find plugin: %s' % name, 'Error') return - module = loader.load_module() + log.debug('Plugin %s loaded from "%s"', name, module.__file__) 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), @@ -88,8 +127,22 @@ class PluginManager: self.event_handlers[name] = [] try: self.plugins[name] = None - self.plugins[name] = module.Plugin(self.plugin_api, self.core, + + for dep in module.Plugin.dependencies: + self.load(dep, unload_first=False) + if dep not in self.plugins: + log.debug( + 'Plugin %s couldn\'t load because of dependency %s', + name, dep + ) + return None + # Add reference of the dep to the plugin's usage + module.Plugin.refs[dep] = self.plugins[dep] + + self.plugins[name] = module.Plugin(name, self.plugin_api, self.core, self.plugins_conf_dir) + self.set_rdeps(name) + except Exception as e: log.error('Error while loading the plugin %s', name, exc_info=True) if notify: @@ -100,9 +153,22 @@ class PluginManager: if notify: self.core.information('Plugin %s loaded' % name, 'Info') - def unload(self, name, notify=True): + def unload(self, name: str, notify=True): + """ + Unloads plugin as well as plugins depending on it. + """ + if name in self.plugins: try: + if self.plugins[name] is not None: + self.plugins[name]._unloading = True # Prevents loops + for rdep in self.rdeps[name].copy(): + if rdep in self.plugins and not self.plugins[rdep]._unloading: + self.unload(rdep) + if rdep in self.plugins: + log.debug('Failed to unload reverse dependency %s first.', rdep) + return None + for command in self.commands[name].keys(): del self.core.commands[command] for key in self.keys[name].keys(): @@ -122,6 +188,7 @@ class PluginManager: if self.plugins[name] is not None: self.plugins[name].unload() del self.plugins[name] + del self.rdeps[name] del self.commands[name] del self.keys[name] del self.tab_commands[name] @@ -253,7 +320,7 @@ class PluginManager: if key in self.core.key_func: del self.core.commands[key] - def add_event_handler(self, module_name, event_name, handler, position=0): + def add_event_handler(self, module_name, event_name, handler, *args, **kwargs): """ Add an event handler. If event_name isn’t in the event list, assume it is a slixmpp event. @@ -261,7 +328,7 @@ class PluginManager: 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) + self.core.events.add_event_handler(event_name, handler, *args, **kwargs) else: self.core.xmpp.add_event_handler(event_name, handler) @@ -326,7 +393,7 @@ class PluginManager: """ Create the plugins_conf_dir """ - plugins_conf_dir = config.get('plugins_conf_dir') + plugins_conf_dir = config.getstr('plugins_conf_dir') self.plugins_conf_dir = Path(plugins_conf_dir).expanduser( ) if plugins_conf_dir else xdg.CONFIG_HOME / 'plugins' self.check_create_plugins_conf_dir() @@ -351,7 +418,7 @@ class PluginManager: """ Set the plugins_dir on start """ - plugins_dir = config.get('plugins_dir') + plugins_dir = config.getstr('plugins_dir') self.plugins_dir = Path(plugins_dir).expanduser( ) if plugins_dir else xdg.DATA_HOME / 'plugins' self.check_create_plugins_dir() @@ -387,11 +454,3 @@ class PluginManager: if os.access(str(self.plugins_dir), os.R_OK | os.X_OK): self.load_path.append(str(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 index 05c8ceed..b149abd4 100644 --- a/poezio/poezio.py +++ b/poezio/poezio.py @@ -3,7 +3,7 @@ # 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. +# it under the terms of the GPL-3.0+ license. See the COPYING file. """ Starting point of poezio. Launches both the Connection and Gui """ @@ -72,56 +72,55 @@ def main(): """ Entry point. """ + + if os.geteuid() == 0: + sys.stdout.write("Please do not run poezio as root.\n") + sys.exit(0) + sys.stdout.write("\x1b]0;poezio\x07") sys.stdout.flush() + from poezio.args import run_cmdline_args + options, firstrun = run_cmdline_args() from poezio import config - config.run_cmdline_args() - config.create_global_config() - config.setup_logging() - config.post_logging_setup() + config.create_global_config(options.filename) + config.setup_logging(options.debug) - from poezio.config import options + import logging + logging.raiseExceptions = False if options.check_config: config.check_config() sys.exit(0) - from poezio.asyncio import monkey_patch_asyncio_slixmpp + from poezio.asyncio_fix import monkey_patch_asyncio_slixmpp monkey_patch_asyncio_slixmpp() from poezio import theming theming.update_themes_dir() - from poezio import logger - logger.create_logger() + from poezio.logger import logger + logger.log_dir = config.LOG_DIR from poezio import roster - roster.create_roster() + roster.roster.reset() from poezio.core.core import Core signal.signal(signal.SIGINT, signal.SIG_IGN) # ignore ctrl-c - cocore = Core() + cocore = Core(options.custom_version, firstrun) 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.set_exception_handler(cocore.loop_exception_handler) loop.add_reader(sys.stdin, cocore.on_input_readable) loop.add_signal_handler(signal.SIGWINCH, cocore.sigwinch_handler) diff --git a/poezio/poezio_shlex.pyi b/poezio/poezio_shlex.pyi new file mode 100644 index 00000000..affbe12b --- /dev/null +++ b/poezio/poezio_shlex.pyi @@ -0,0 +1,45 @@ +from typing import List, Tuple, Any, TextIO, Union, Optional, Iterable, TypeVar +import sys + +def split(s: str, comments: bool = ..., posix: bool = ...) -> List[str]: ... +if sys.version_info >= (3, 8): + def join(split_command: Iterable[str]) -> str: ... +def quote(s: str) -> str: ... + +_SLT = TypeVar('_SLT', bound=shlex) + +class shlex(Iterable[str]): + commenters: str + wordchars: str + whitespace: str + escape: str + quotes: str + escapedquotes: str + whitespace_split: bool + infile: str + instream: TextIO + source: str + debug: int + lineno: int + token: str + eof: str + if sys.version_info >= (3, 6): + punctuation_chars: str + + if sys.version_info >= (3, 6): + def __init__(self, instream: Union[str, TextIO] = ..., infile: Optional[str] = ..., + posix: bool = ..., punctuation_chars: Union[bool, str] = ...) -> None: ... + else: + def __init__(self, instream: Union[str, TextIO] = ..., infile: Optional[str] = ..., + posix: bool = ...) -> None: ... + def get_token(self) -> Tuple[int, int, str]: ... + def push_token(self, tok: str) -> None: ... + def read_token(self) -> str: ... + def sourcehook(self, filename: str) -> Tuple[str, TextIO]: ... + # TODO argument types + def push_source(self, newstream: Any, newfile: Any = ...) -> None: ... + def pop_source(self) -> None: ... + def error_leader(self, infile: str = ..., + lineno: int = ...) -> None: ... + def __iter__(self: _SLT) -> _SLT: ... + def __next__(self) -> str: ... diff --git a/poezio/poopt.py b/poezio/poopt.py deleted file mode 100644 index 57bd28c8..00000000 --- a/poezio/poopt.py +++ /dev/null @@ -1,185 +0,0 @@ -# Copyright 2017 Emmanuel Gil Peyrot <linkmauve@linkmauve.fr> -# -# 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 is a template module just for instruction. And poopt.''' - -from typing import List, Tuple - -# CFFI codepath. -from cffi import FFI - -ffi = FFI() -ffi.cdef(""" - typedef long wchar_t; - int wcwidth(wchar_t c); -""") -libc = ffi.dlopen(None) - -# Cython codepath. -#cdef extern from "wchar.h": -# ctypedef Py_UCS4 wchar_t -# int wcwidth(wchar_t c) - - -# Just checking if the return value is -1. In some (all?) implementations, -# wcwidth("😆") returns -1 while it should return 2. 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. -def xwcwidth(c: str) -> int: - character = ord(c) - res = libc.wcwidth(character) - if res == -1 and c != '\x19': - return 1 - return res - - -# 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" -def cut_text(string: str, width: int) -> List[Tuple[int, int]]: - '''cut_text(text, width) - - Return a list of two-tuple, the first int is the starting position of the line and the second is its end.''' - - # The list of tuples that we return - retlist = [] - - # 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 - - # Number of columns taken to display the current line so far - #: size_t - columns = 0 - - #: wchar_t - #wc = 0 - - # 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 = -1 - - in_special_character = False - for spos, wc in enumerate(string): - # Special case to skip 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 in_special_character: - # Skip 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. - if wc in ('u', 'a', 'i', 'b', 'o', '}'): - in_special_character = False - continue - if wc == '\x19': - in_special_character = True - continue - - # This is one condition to end the line: an explicit \n is found - if wc == '\n': - spos += 1 - retlist.append((start_pos, spos)) - - # 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 - 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: - retlist.append((start_pos, last_space)) - start_pos = last_space + 1 - last_space = -1 - columns -= (cols_until_space + 1) - else: - # Otherwise, cut in the middle of a word - retlist.append((start_pos, spos)) - 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 == ' ': - 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 - # We are at the end of the string, append the last line, not finished - retlist.append((start_pos, spos + 1)) - return retlist - - -# wcswidth: An emulation of the POSIX wcswidth(3) function using xwcwidth. -def wcswidth(string: str) -> int: - '''wcswidth(s) - - The 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''' - - columns = 0 - for wc in string: - columns += xwcwidth(wc) - return columns - - -# 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 -def cut_by_columns(string: str, limit: int) -> str: - '''cut_by_columns(string, limit) - - returns a string truncated to take at most limit columns''' - - spos = 0 - columns = 0 - for wc in string: - if columns == limit: - break - cols = xwcwidth(wc) - if columns + cols > limit: - break - spos += 1 - columns += cols - return string[:spos] diff --git a/poezio/poopt.pyi b/poezio/poopt.pyi new file mode 100644 index 00000000..3762c94a --- /dev/null +++ b/poezio/poopt.pyi @@ -0,0 +1,7 @@ + +from typing import List, Tuple + +def xwcwidth(c: str) -> int: ... +def cut_text(string: str, width: int) -> List[Tuple[int, int]]: ... +def wcswidth(string: str) -> int: ... +def cut_by_columns(string: str, limit: int) -> str: ... diff --git a/poezio/pooptmodule.c b/poezio/pooptmodule.c index 427ac883..8574b225 100644 --- a/poezio/pooptmodule.c +++ b/poezio/pooptmodule.c @@ -3,7 +3,7 @@ /* 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. */ +/* it under the terms of the GPL-3.0+ license. See the COPYING file. */ /** The poopt python3 module **/ diff --git a/poezio/py.typed b/poezio/py.typed new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/poezio/py.typed diff --git a/poezio/roster.py b/poezio/roster.py index bedf477b..a52ea23e 100644 --- a/poezio/roster.py +++ b/poezio/roster.py @@ -3,12 +3,13 @@ # 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. +# it under the terms of the GPL-3.0+ license. See the COPYING file. """ Defines the Roster and RosterGroup classes """ import logging -log = logging.getLogger(__name__) + +from typing import List from poezio.config import config from poezio.contact import Contact @@ -16,9 +17,10 @@ from poezio.roster_sorting import SORTING_METHODS, GROUP_SORTING_METHODS from os import path as p from datetime import datetime -from poezio.common import safeJID from slixmpp.exceptions import IqError, IqTimeout +from slixmpp import JID, InvalidJID +log = logging.getLogger(__name__) class Roster: """ @@ -29,6 +31,22 @@ class Roster: DEFAULT_FILTER = (lambda x, y: None, None) def __init__(self): + self.__node = None + + # A tuple(function, *args) function to filter contacts + # on search, for example + self.contact_filter = self.DEFAULT_FILTER + self.groups = {} + self.contacts = {} + self.length = 0 + self.connected = 0 + self.folded_groups = [] + + # Used for caching roster infos + self.last_built = datetime.now() + self.last_modified = datetime.now() + + def reset(self): """ node: the RosterSingle from slixmpp """ @@ -38,7 +56,8 @@ class Roster: # on search, for example self.contact_filter = self.DEFAULT_FILTER self.folded_groups = set( - config.get('folded_roster_groups', section='var').split(':')) + config.getlist('folded_roster_groups', section='var') + ) self.groups = {} self.contacts = {} self.length = 0 @@ -52,12 +71,15 @@ class Roster: self.last_modified = datetime.now() @property - def needs_rebuild(self): + def needs_rebuild(self) -> bool: return self.last_modified >= self.last_built def __getitem__(self, key): """Get a Contact from his bare JID""" - key = safeJID(key).bare + try: + key = JID(key).bare + except InvalidJID: + return None if key in self.contacts and self.contacts[key] is not None: return self.contacts[key] if key in self.jids(): @@ -71,7 +93,10 @@ class Roster: def remove(self, jid): """Send a removal iq to the server""" - jid = safeJID(jid).bare + try: + jid = JID(jid).bare + except InvalidJID: + return if self.__node[jid]: try: self.__node[jid].send_presence(ptype='unavailable') @@ -81,7 +106,10 @@ class Roster: def __delitem__(self, jid): """Remove a contact from the roster view""" - jid = safeJID(jid).bare + try: + jid = JID(jid).bare + except InvalidJID: + return contact = self[jid] if not contact: return @@ -99,10 +127,13 @@ class Roster: def __contains__(self, key): """True if the bare jid is in the roster, false otherwise""" - return safeJID(key).bare in self.jids() + try: + return JID(key).bare in self.jids() + except InvalidJID: + return False @property - def jid(self): + def jid(self) -> JID: """Our JID""" return self.__node.jid @@ -143,7 +174,7 @@ class Roster: """Subscribe to a jid""" self.__node.subscribe(jid) - def jids(self): + def jids(self) -> List[JID]: """List of the contact JIDS""" l = [] for key in self.__node.keys(): @@ -335,11 +366,6 @@ class RosterGroup: 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 +roster = Roster() diff --git a/poezio/size_manager.py b/poezio/size_manager.py index 3e80c357..c5312c9f 100644 --- a/poezio/size_manager.py +++ b/poezio/size_manager.py @@ -18,21 +18,25 @@ class SizeManager: self._core = core @property - def tab_degrade_x(self): + def tab_degrade_x(self) -> bool: + if base_wins.TAB_WIN is None: + raise ValueError _, x = base_wins.TAB_WIN.getmaxyx() return x < THRESHOLD_WIDTH_DEGRADE @property - def tab_degrade_y(self): + def tab_degrade_y(self) -> bool: + if base_wins.TAB_WIN is None: + raise ValueError y, x = base_wins.TAB_WIN.getmaxyx() return y < THRESHOLD_HEIGHT_DEGRADE @property - def core_degrade_x(self): + def core_degrade_x(self) -> bool: y, x = self._core.stdscr.getmaxyx() return x < FULL_WIDTH_DEGRADE @property - def core_degrade_y(self): + def core_degrade_y(self) -> bool: y, x = self._core.stdscr.getmaxyx() return y < FULL_HEIGHT_DEGRADE diff --git a/poezio/tabs/adhoc_commands_list.py b/poezio/tabs/adhoc_commands_list.py index b62166b0..3b6bc1db 100644 --- a/poezio/tabs/adhoc_commands_list.py +++ b/poezio/tabs/adhoc_commands_list.py @@ -16,8 +16,8 @@ log = logging.getLogger(__name__) class AdhocCommandsListTab(ListTab): - plugin_commands = {} # type: Dict[str, Command] - plugin_keys = {} # type: Dict[str, Callable] + plugin_commands: Dict[str, Command] = {} + plugin_keys: Dict[str, Callable] = {} def __init__(self, core, jid): ListTab.__init__( diff --git a/poezio/tabs/basetabs.py b/poezio/tabs/basetabs.py index 578668fc..793eae62 100644 --- a/poezio/tabs/basetabs.py +++ b/poezio/tabs/basetabs.py @@ -13,26 +13,57 @@ This module also defines ChatTabs, the parent class for all tabs revolving around chats. """ +from __future__ import annotations + import logging import string -import time +import asyncio +from copy import copy +from math import ceil, log10 from datetime import datetime -from xml.etree import cElementTree as ET -from typing import Any, Callable, Dict, List, Optional - -from slixmpp import JID, Message - +from xml.etree import ElementTree as ET +from xml.sax import SAXParseException +from typing import ( + Any, + Callable, + cast, + Dict, + List, + Optional, + Union, + Tuple, + TYPE_CHECKING, +) + +from poezio import ( + poopt, + timed_events, + xhtml, + windows +) from poezio.core.structs import Command, Completion, Status -from poezio import timed_events -from poezio import windows -from poezio import xhtml -from poezio.common import safeJID from poezio.config import config -from poezio.decorators import refresh_wrapper +from poezio.decorators import command_args_parser, refresh_wrapper from poezio.logger import logger +from poezio.log_loader import MAMFiller, LogLoader from poezio.text_buffer import TextBuffer from poezio.theming import get_theme, dump_tuple -from poezio.decorators import command_args_parser +from poezio.user import User +from poezio.ui.funcs import truncate_nick +from poezio.timed_events import DelayedEvent +from poezio.ui.types import ( + BaseMessage, + Message, + PersistentInfoMessage, + LoggableTrait, +) + +from slixmpp import JID, InvalidJID, Message as SMessage + +if TYPE_CHECKING: + from _curses import _CursesWindow # pylint: disable=E0611 + from poezio.size_manager import SizeManager + from poezio.core.core import Core log = logging.getLogger(__name__) @@ -90,29 +121,42 @@ SHOW_NAME = { class Tab: - plugin_commands = {} # type: Dict[str, Command] - plugin_keys = {} # type: Dict[str, Callable] + plugin_commands: Dict[str, Command] = {} + plugin_keys: Dict[str, Callable] = {} # Placeholder values, set on resize - height = 1 - width = 1 - - def __init__(self, core): + height: int = 1 + width: int = 1 + core: Core + input: Optional[windows.Input] + key_func: Dict[str, Callable[[], Any]] + commands: Dict[str, Command] + need_resize: bool + ui_config_changed: bool + + def __init__(self, core: Core): self.core = core self.nb = 0 - if not hasattr(self, 'name'): - self.name = self.__class__.__name__ + 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.ui_config_changed = 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) -> int: + def name(self) -> str: + if hasattr(self, '_name'): + return self._name + return '' + + @property + def size(self) -> SizeManager: return self.core.size @staticmethod @@ -121,23 +165,27 @@ class Tab: Returns 1 or 0, depending on if we are using the vertical tab list or not. """ - if config.get('enable_vertical_tab_list'): + if config.getbool('enable_vertical_tab_list'): return 0 return 1 @property - def info_win(self): + def info_win(self) -> windows.TextWin: return self.core.information_win @property - def color(self): + def color(self) -> Union[Tuple[int, int], Tuple[int, int, 'str']]: return STATE_COLORS[self._state]() @property - def vertical_color(self): + def vertical_color(self) -> Union[Tuple[int, int], Tuple[int, int, 'str']]: return VERTICAL_STATE_COLORS[self._state]() @property + def priority(self) -> Union[int, float]: + return STATE_PRIORITY.get(self._state, -1) + + @property def state(self) -> str: return self._state @@ -175,7 +223,7 @@ class Tab: self._state = 'normal' @staticmethod - def resize(scr): + def initial_resize(scr: _CursesWindow): Tab.height, Tab.width = scr.getmaxyx() windows.base_wins.TAB_WIN = scr @@ -212,7 +260,7 @@ class Tab: *, desc='', shortdesc='', - completion: Optional[Callable] = None, + completion: Optional[Callable[[windows.Input], Completion]] = None, usage=''): """ Add a command @@ -241,7 +289,7 @@ class Tab: ['/%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. + # one possibility. 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) @@ -264,7 +312,6 @@ class Tab: comp = command.comp(the_input) if comp: return comp.run() - return comp return False def execute_command(self, provided_text: str) -> bool: @@ -272,8 +319,10 @@ class Tab: Execute the command in the input and return False if the input didn't contain a command """ + if self.input is None: + raise NotImplementedError txt = provided_text or self.input.key_enter() - if txt.startswith('/') and not txt.startswith('//') and\ + if txt and 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 ' ' @@ -301,13 +350,16 @@ class Tab: if func: if hasattr(self.input, "reset_completion"): self.input.reset_completion() - func(arg) + if asyncio.iscoroutinefunction(func): + asyncio.create_task(func(arg)) + else: + func(arg) return True else: return False - def refresh_tab_win(self): - if config.get('enable_vertical_tab_list'): + def refresh_tab_win(self) -> None: + if config.getbool('enable_vertical_tab_list'): left_tab_win = self.core.left_tab_win if left_tab_win and not self.size.core_degrade_x: left_tab_win.refresh() @@ -338,24 +390,18 @@ class Tab: """ return self.name - def get_text_window(self) -> Optional[windows.TextWin]: - """ - Returns the principal TextWin window, if there's one - """ - return None - def on_input(self, key: str, raw: bool): """ raw indicates if the key should activate the associated command or not. """ pass - def update_commands(self): + def update_commands(self) -> None: for c in self.plugin_commands: if c not in self.commands: self.commands[c] = self.plugin_commands[c] - def update_keys(self): + def update_keys(self) -> None: for k in self.plugin_keys: if k not in self.key_func: self.key_func[k] = self.plugin_keys[k] @@ -414,7 +460,7 @@ class Tab: """ pass - def on_close(self): + def on_close(self) -> None: """ Called when the tab is to be closed """ @@ -422,7 +468,7 @@ class Tab: self.input.on_delete() self.closed = True - def matching_names(self) -> List[str]: + def matching_names(self) -> List[Tuple[int, str]]: """ 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 @@ -436,6 +482,9 @@ class Tab: class GapTab(Tab): + def __init__(self): + return + def __bool__(self): return False @@ -443,7 +492,7 @@ class GapTab(Tab): return 0 @property - def name(self): + def name(self) -> str: return '' def refresh(self): @@ -458,23 +507,37 @@ class ChatTab(Tab): Also, ^M is already bound to on_enter And also, add the /say command """ - plugin_commands = {} # type: Dict[str, Command] - plugin_keys = {} # type: Dict[str, Callable] + plugin_commands: Dict[str, Command] = {} + plugin_keys: Dict[str, Callable] = {} + last_sent_message: Optional[SMessage] message_type = 'chat' + timed_event_paused: Optional[DelayedEvent] + timed_event_not_paused: Optional[DelayedEvent] + mam_filler: Optional[MAMFiller] + e2e_encryption: Optional[str] = None - def __init__(self, core, jid=''): + def __init__(self, core, jid: Union[JID, str]): Tab.__init__(self, core) - self.name = jid - self.text_win = None + + if not isinstance(jid, JID): + jid = JID(jid) + assert jid.domain + self._jid = jid + #: Is the tab currently requesting MAM data? + self.query_status = False + self._name = jid.full + self.text_win = windows.TextWin() self.directed_presence = None self._text_buffer = TextBuffer() + self._text_buffer.add_window(self.text_win) + self.mam_filler = None 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 self.timed_event_not_paused = None # Keeps the last sent message to complete it easily in completion_correct, and to replace it. - self.last_sent_message = {} + 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 @@ -485,6 +548,12 @@ class ChatTab(Tab): usage='<message>', shortdesc='Send the message.') self.register_command( + 'scrollback', + self.command_scrollback, + usage="end home clear status goto <+|-linecount>|<linenum>|<timestamp>", + shortdesc='Scrollback to the given line number, message, or clear the buffer.') + self.commands['sb'] = self.commands['scrollback'] + self.register_command( 'xhtml', self.command_xhtml, usage='<custom xhtml>', @@ -497,73 +566,79 @@ class ChatTab(Tab): desc='Fix the last message with whatever you want.', shortdesc='Correct the last message.', completion=self.completion_correct) - self.chat_state = None + self.chat_state: Optional[str] = None self.update_commands() self.update_keys() - # Get the logs - log_nb = config.get('load_log') - logs = self.load_logs(log_nb) + @property + def name(self) -> str: + if self._name is not None: + return self._name + return self._jid.full + + @name.setter + def name(self, value: Union[JID, str]) -> None: + if isinstance(value, JID): + self.jid = value + elif isinstance(value, str): + try: + value = JID(value) + if value.domain: + self._jid = value + except InvalidJID: + self._name = str(value) + else: + raise TypeError("Name %r must be of type JID or str." % value) - if logs: - for message in logs: - self._text_buffer.add_message(**message) + @property + def log_name(self) -> str: + """Name used for the log filename""" + return self.jid.bare @property - def general_jid(self) -> JID: - return NotImplementedError + def jid(self) -> JID: + return copy(self._jid) - def load_logs(self, log_nb: int) -> Optional[List[Dict[str, Any]]]: - logs = logger.get_logs(safeJID(self.name).bare, log_nb) - return logs + @jid.setter + def jid(self, value: JID) -> None: + if not isinstance(value, JID): + raise TypeError("Jid %r must be of type JID." % value) + assert value.domain + self._jid = value + + @property + def general_jid(self) -> JID: + raise NotImplementedError - def log_message(self, - txt: str, - nickname: str, - time: Optional[datetime] = None, - typ=1): + def log_message(self, message: BaseMessage): """ Log the messages in the archives. """ - name = safeJID(self.name).bare - if not logger.log_message(name, nickname, txt, date=time, typ=typ): + if not isinstance(message, LoggableTrait): + return + if not logger.log_message(self.log_name, message): 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 add_message(self, message: BaseMessage): + self.log_message(message) + self._text_buffer.add_message(message) def modify_message(self, - txt, - old_id, - new_id, - user=None, - jid=None, - nickname=None): - self.log_message(txt, nickname, typ=1) + txt: str, + old_id: str, + new_id: str, + time: Optional[datetime], + delayed: bool = False, + nickname: Optional[str] = None, + user: Optional[User] = None, + jid: Optional[JID] = None, + ) -> bool: message = self._text_buffer.modify_message( - txt, old_id, new_id, time=time, user=user, jid=jid) + txt, old_id, new_id, user=user, jid=jid, time=time + ) if message: - self.text_win.modify_message(old_id, message) + self.log_message(message) + self.text_win.modify_message(message.identifier, message) self.core.refresh_window() return True return False @@ -584,16 +659,20 @@ class ChatTab(Tab): 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]) + words.extend([word for word in config.getlist('words') if word]) self.input.auto_completion(words, ' ', quotify=False) def on_enter(self): + if self.input is None: + raise NotImplementedError 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)) + asyncio.ensure_future( + self.command_say(xhtml.convert_simple_to_full_colors(txt)) + ) self.cancel_paused_delay() @command_args_parser.raw @@ -605,26 +684,26 @@ class ChatTab(Tab): if message: message.send() - def generate_xhtml_message(self, arg: str) -> Message: + def generate_xhtml_message(self, arg: str) -> Optional[SMessage]: if not arg: - return + return None try: body = xhtml.clean_text( xhtml.xhtml_to_poezio_colors(arg, force=True)) ET.fromstring(arg) - except: + except SAXParseException: self.core.information('Could not send custom xhtml', 'Error') - log.error('/xhtml: Unable to send custom xhtml', exc_info=True) - return + log.error('/xhtml: Unable to send custom xhtml') + return None - msg = self.core.xmpp.make_message(self.get_dest_jid()) + msg: SMessage = 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) -> JID: - return self.name + return self.jid @refresh_wrapper.always def command_clear(self, ignored): @@ -634,27 +713,31 @@ class ChatTab(Tab): self._text_buffer.messages = [] self.text_win.rebuild_everything(self._text_buffer) - def check_send_chat_state(self): + def check_send_chat_state(self) -> bool: "If we should send a chat state" return True - def send_chat_state(self, state, always_send=False): + def send_chat_state(self, state: str, always_send: bool = False) -> None: """ Send an empty chatstate message """ + from poezio.tabs import PrivateTab + if self.check_send_chat_state(): 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): - msg = self.core.xmpp.make_message(self.get_dest_jid()) + msg: SMessage = self.core.xmpp.make_message(self.get_dest_jid()) msg['type'] = self.message_type msg['chat_state'] = state self.chat_state = state + msg['no-store'] = True + if isinstance(self, PrivateTab): + msg.enable('muc') msg.send() - return True - def send_composing_chat_state(self, empty_after): + def send_composing_chat_state(self, empty_after: bool) -> None: """ Send the "active" or "composing" chatstate, depending on the the current status of the input @@ -690,7 +773,7 @@ class ChatTab(Tab): self.core.add_timed_event(new_event) self.timed_event_not_paused = new_event - def cancel_paused_delay(self): + def cancel_paused_delay(self) -> None: """ Remove that event from the list and set it to None. Called for example when the input is emptied, or when the message @@ -699,11 +782,22 @@ class ChatTab(Tab): if self.timed_event_paused is not None: self.core.remove_timed_event(self.timed_event_paused) self.timed_event_paused = None - self.core.remove_timed_event(self.timed_event_not_paused) - self.timed_event_not_paused = None + if self.timed_event_not_paused is not None: + self.core.remove_timed_event(self.timed_event_not_paused) + self.timed_event_not_paused = None + + def set_last_sent_message(self, msg: SMessage, correct: bool = False) -> None: + """Ensure last_sent_message is set with the correct attributes""" + if correct: + # XXX: Is the copy needed. Is the object passed here reused + # afterwards? Who knows. + msg = cast(SMessage, copy(msg)) + if self.last_sent_message is not None: + msg['id'] = self.last_sent_message['id'] + self.last_sent_message = msg @command_args_parser.raw - def command_correct(self, line): + async def command_correct(self, line: str) -> None: """ /correct <fixed message> """ @@ -713,7 +807,7 @@ class ChatTab(Tab): if not self.last_sent_message: self.core.information('There is no message to correct.', 'Error') return - self.command_say(line, correct=True) + await self.command_say(line, correct=True) def completion_correct(self, the_input): if self.last_sent_message and the_input.get_argument_position() == 1: @@ -726,26 +820,153 @@ class ChatTab(Tab): @property def inactive(self) -> bool: """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) + return self.core.status.show in ('xa', 'away') or ( + hasattr(self, 'directed_presence') + and self.directed_presence is not None + and self.directed_presence + ) - def move_separator(self): + def move_separator(self) -> None: self.text_win.remove_line_separator() self.text_win.add_line_separator(self._text_buffer) self.text_win.refresh() - self.input.refresh() + if self.input: + self.input.refresh() def get_conversation_messages(self): return self._text_buffer.messages - def check_scrolled(self): + def check_scrolled(self) -> None: if self.text_win.pos != 0: self.state = 'scrolled' @command_args_parser.raw - def command_say(self, line, correct=False): + async def command_say(self, line: str, attention: bool = False, correct: bool = False): pass + def goto_build_lines(self, new_date): + text_buffer = self._text_buffer + built_lines = [] + message_count = 0 + timestamp = config.getbool('show_timestamps') + nick_size = config.getint('max_nick_length') + theme = get_theme() + for message in text_buffer.messages: + # Build lines of a message + txt = message.txt + nick = truncate_nick(message.nickname, nick_size) + offset = 0 + theme = get_theme() + if message.ack: + if message.ack > 0: + offset += poopt.wcswidth(theme.CHAR_ACK_RECEIVED) + 1 + else: + offset += poopt.wcswidth(theme.CHAR_NACK) + 1 + if nick: + offset += poopt.wcswidth(nick) + 2 + if message.revisions > 0: + offset += ceil(log10(message.revisions + 1)) + if message.me: + offset += 1 + if timestamp: + if message.history: + offset += 1 + theme.LONG_TIME_FORMAT_LENGTH + lines = poopt.cut_text(txt, self.text_win.width - offset - 1) + for line in lines: + built_lines.append(line) + # Find the message with timestamp less than or equal to the queried + # timestamp and goto that location in the tab. + if message.time <= new_date: + message_count += 1 + if len(self.text_win.built_lines) - self.text_win.height >= len(built_lines): + self.text_win.pos = len(self.text_win.built_lines) - self.text_win.height - len(built_lines) + 1 + else: + self.text_win.pos = 0 + if message_count == 0: + self.text_win.scroll_up(len(self.text_win.built_lines)) + self.core.refresh_window() + + @command_args_parser.quoted(0, 2) + def command_scrollback(self, args): + """ + /sb clear + /sb home + /sb end + /sb goto <+|-linecount>|<linenum>|<timestamp> + The format of timestamp must be ‘[dd[.mm]-<days ago>] hh:mi[:ss]’ + """ + if args is None or len(args) == 0: + args = ['end'] + if len(args) == 1: + if args[0] == 'end': + self.text_win.scroll_down(len(self.text_win.built_lines)) + self.core.refresh_window() + return + elif args[0] == 'home': + self.text_win.scroll_up(len(self.text_win.built_lines)) + self.core.refresh_window() + return + elif args[0] == 'clear': + self._text_buffer.messages = [] + self.text_win.rebuild_everything(self._text_buffer) + self.core.refresh_window() + return + elif args[0] == 'status': + self.core.information('Total %s lines in this tab.' % len(self.text_win.built_lines), 'Info') + return + elif len(args) == 2 and args[0] == 'goto': + for fmt in ('%d %H:%M', '%d %H:%M:%S', '%d:%m %H:%M', '%d:%m %H:%M:%S', '%H:%M', '%H:%M:%S'): + try: + new_date = datetime.strptime(args[1], fmt) + if 'd' in fmt and 'm' in fmt: + new_date = new_date.replace(year=datetime.now().year) + elif 'd' in fmt: + new_date = new_date.replace(year=datetime.now().year, month=datetime.now().month) + else: + new_date = new_date.replace(year=datetime.now().year, month=datetime.now().month, day=datetime.now().day) + except ValueError: + pass + if args[1].startswith('-'): + # Check if the user is giving argument of type goto <-linecount> or goto [-<days ago>] hh:mi[:ss] + if ' ' in args[1]: + new_args = args[1].split(' ') + new_args[0] = new_args[0].strip('-') + new_date = datetime.now() + if new_args[0].isdigit(): + new_date = new_date.replace(day=new_date.day - int(new_args[0])) + for fmt in ('%H:%M', '%H:%M:%S'): + try: + arg_date = datetime.strptime(new_args[1], fmt) + new_date = new_date.replace(hour=arg_date.hour, minute=arg_date.minute, second=arg_date.second) + except ValueError: + pass + else: + scroll_len = args[1].strip('-') + if scroll_len.isdigit(): + self.text_win.scroll_down(int(scroll_len)) + self.core.refresh_window() + return + elif args[1].startswith('+'): + scroll_len = args[1].strip('+') + if scroll_len.isdigit(): + self.text_win.scroll_up(int(scroll_len)) + self.core.refresh_window() + return + # Check for the argument of type goto <linenum> + elif args[1].isdigit(): + if len(self.text_win.built_lines) - self.text_win.height >= int(args[1]): + self.text_win.pos = len(self.text_win.built_lines) - self.text_win.height - int(args[1]) + self.core.refresh_window() + return + else: + self.text_win.pos = 0 + self.core.refresh_window() + return + elif args[1] == '0': + args = ['home'] + # new_date is the timestamp for which the user has queried. + self.goto_build_lines(new_date) + def on_line_up(self): return self.text_win.scroll_up(1) @@ -753,6 +974,11 @@ class ChatTab(Tab): return self.text_win.scroll_down(1) def on_scroll_up(self): + if not self.query_status: + from poezio.log_loader import LogLoader + asyncio.create_task( + LogLoader(logger, self, config.getbool('use_log')).scroll_requested() + ) return self.text_win.scroll_up(self.text_win.height - 1) def on_scroll_down(self): @@ -770,15 +996,15 @@ class ChatTab(Tab): class OneToOneTab(ChatTab): - def __init__(self, core, jid=''): + def __init__(self, core, jid, initial=None): ChatTab.__init__(self, core, jid) self.__status = Status("", "") self.last_remote_message = datetime.now() + self._initial_log = asyncio.Event() # Set to true once the first disco is done self.__initial_disco = False - self.check_features() self.register_command( 'unquery', self.command_unquery, shortdesc='Close the tab.') self.register_command( @@ -790,6 +1016,30 @@ class OneToOneTab(ChatTab): shortdesc='Request the attention.', desc='Attention: Request the attention of the contact. Can also ' 'send a message along with the attention.') + asyncio.create_task(self.init_logs(initial=initial)) + + async def init_logs(self, initial: Optional[SMessage] = None) -> None: + use_log = config.get_by_tabname('use_log', self.jid) + mam_sync = config.get_by_tabname('mam_sync', self.jid) + if use_log and mam_sync: + limit = config.get_by_tabname('mam_sync_limit', self.jid) + mam_filler = MAMFiller(logger, self, limit) + self.mam_filler = mam_filler + + if initial is not None: + # If there is an initial message, throw it back into the + # text buffer if it cannot be fetched from mam + await mam_filler.done.wait() + if mam_filler.result == 0: + await self.handle_message(initial) + elif use_log and initial: + await self.handle_message(initial, display=False) + elif initial: + await self.handle_message(initial) + await LogLoader(logger, self, use_log, self._initial_log).tab_open() + + async def handle_message(self, msg: SMessage, display: bool = True): + pass def remote_user_color(self): return dump_tuple(get_theme().COLOR_REMOTE_USER) @@ -801,7 +1051,7 @@ class OneToOneTab(ChatTab): return self.__status = status hide_status_change = config.get_by_tabname('hide_status_change', - safeJID(self.name).bare) + self.jid.bare) now = datetime.now() dff = now - self.last_remote_message if hide_status_change > -1 and dff.total_seconds() > hide_status_change: @@ -816,9 +1066,11 @@ class OneToOneTab(ChatTab): msg += 'status: %s, ' % status.message if status.show in SHOW_NAME: msg += 'show: %s, ' % SHOW_NAME[status.show] - self.add_message(msg[:-2], typ=2) + self.add_message( + PersistentInfoMessage(txt=msg[:-2]) + ) - def ack_message(self, msg_id, msg_jid): + def ack_message(self, msg_id: str, msg_jid: JID): """ Ack a message """ @@ -827,9 +1079,9 @@ class OneToOneTab(ChatTab): self.text_win.modify_message(msg_id, new_msg) self.core.refresh_window() - def nack_message(self, error, msg_id, msg_jid): + def nack_message(self, error: str, msg_id: str, msg_jid: JID): """ - Ack a message + Non-ack a message (e.g. timeout) """ new_msg = self._text_buffer.nack_message(error, msg_id, msg_jid) if new_msg: @@ -848,26 +1100,21 @@ class OneToOneTab(ChatTab): message.send() body = xhtml.xhtml_to_poezio_colors(xhtml_data, force=True) self._text_buffer.add_message( - body, - nickname=self.core.own_nick, - nick_color=get_theme().COLOR_OWN_NICK, - identifier=message['id'], - jid=self.core.xmpp.boundjid) + Message( + body, + nickname=self.core.own_nick, + nick_color=get_theme().COLOR_OWN_NICK, + identifier=message['id'], + jid=self.core.xmpp.boundjid, + ) + ) 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): + async def command_attention(self, message): """/attention [message]""" - if message is not '': - self.command_say(message, attention=True) + if message != '': + await self.command_say(message, attention=True) else: msg = self.core.xmpp.make_message(self.get_dest_jid()) msg['type'] = 'chat' @@ -875,7 +1122,7 @@ class OneToOneTab(ChatTab): msg.send() @command_args_parser.raw - def command_say(self, line, correct=False, attention=False): + async def command_say(self, line: str, attention: bool = False, correct: bool = False): pass @command_args_parser.ignored @@ -899,7 +1146,3 @@ class OneToOneTab(ChatTab): msg = msg % (self.name, feature, command_name) self.core.information(msg, 'Info') return True - - def features_checked(self, iq): - "Features check callback" - features = iq['disco_info'].get_features() or [] diff --git a/poezio/tabs/bookmarkstab.py b/poezio/tabs/bookmarkstab.py index 816402a7..d21b5630 100644 --- a/poezio/tabs/bookmarkstab.py +++ b/poezio/tabs/bookmarkstab.py @@ -2,14 +2,18 @@ Defines the data-forms Tab """ +import asyncio import logging from typing import Dict, Callable, List +from slixmpp.exceptions import IqError, IqTimeout + from poezio import windows from poezio.bookmarks import Bookmark, BookmarkList from poezio.core.structs import Command from poezio.tabs import Tab -from poezio.common import safeJID + +from slixmpp import JID, InvalidJID log = logging.getLogger(__name__) @@ -19,20 +23,19 @@ 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 = {} # type: Dict[str, Command] - plugin_keys = {} # type: Dict[str, Callable] + plugin_commands: Dict[str, Command] = {} + plugin_keys: Dict[str, Callable] = {} def __init__(self, core, bookmarks: BookmarkList): Tab.__init__(self, core) - self.name = "Bookmarks" + self._name = "Bookmarks" self.bookmarks = bookmarks - self.new_bookmarks = [] # type: List[Bookmark] - self.removed_bookmarks = [] # type: List[Bookmark] + self.new_bookmarks: List[Bookmark] = [] + self.removed_bookmarks: List[Bookmark] = [] self.header_win = windows.ColumnHeaderWin( - ('name', 'room@server/nickname', 'password', 'autojoin', - 'storage')) - self.bookmarks_win = windows.BookmarksWin( - self.bookmarks, self.height - 4, self.width, 1, 0) + ['name', 'room@server/nickname', 'password', 'autojoin', + 'storage']) + self.bookmarks_win = windows.BookmarksWin(self.bookmarks) self.help_win = windows.HelpText('Ctrl+Y: save, Ctrl+G: cancel, ' '↑↓: change lines, tab: change ' 'column, M-a: add bookmark, C-k' @@ -50,7 +53,7 @@ class BookmarksTab(Tab): def add_bookmark(self): new_bookmark = Bookmark( - safeJID('room@example.tld/nick'), method='local') + JID('room@example.tld/nick'), method='local') self.new_bookmarks.append(new_bookmark) self.bookmarks_win.add_bookmark(new_bookmark) @@ -78,26 +81,31 @@ class BookmarksTab(Tab): 'Duplicate bookmarks in list (saving aborted)', 'Error') return for bm in self.new_bookmarks: - if safeJID(bm.jid): + try: + JID(bm.jid) if not self.bookmarks[bm.jid]: self.bookmarks.append(bm) - else: + except InvalidJID: 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') + asyncio.create_task( + self.save_routine() + ) - self.bookmarks.save(self.core.xmpp, callback=send_cb) + async def save_routine(self): + try: + await self.bookmarks.save(self.core.xmpp) + self.core.information('Bookmarks saved', 'Info') + except (IqError, IqTimeout): + self.core.information('Remote bookmarks not saved.', 'Error') self.core.close_tab(self) return True @@ -108,7 +116,7 @@ class BookmarksTab(Tab): return res self.bookmarks_win.refresh_current_input() else: - self.bookmarks_win.on_input(key) + self.bookmarks_win.on_input(key, raw=raw) def resize(self): self.need_resize = False diff --git a/poezio/tabs/confirmtab.py b/poezio/tabs/confirmtab.py index c13de4a6..d7488de7 100644 --- a/poezio/tabs/confirmtab.py +++ b/poezio/tabs/confirmtab.py @@ -13,8 +13,8 @@ log = logging.getLogger(__name__) class ConfirmTab(Tab): - plugin_commands = {} # type: Dict[str, Command] - plugin_keys = {} # type: Dict[str, Callable] + plugin_commands: Dict[str, Command] = {} + plugin_keys: Dict[str, Callable] = {} def __init__(self, core, @@ -34,7 +34,7 @@ class ConfirmTab(Tab): """ Tab.__init__(self, core) self.state = 'highlight' - self.name = name + self._name = name self.default_help_message = windows.HelpText( "Choose with arrow keys and press enter") self.input = self.default_help_message diff --git a/poezio/tabs/conversationtab.py b/poezio/tabs/conversationtab.py index 94f1d719..de1f988a 100644 --- a/poezio/tabs/conversationtab.py +++ b/poezio/tabs/conversationtab.py @@ -11,45 +11,46 @@ There are two different instances of a ConversationTab: the time. """ +import asyncio import curses import logging +from datetime import datetime from typing import Dict, Callable +from slixmpp import JID, InvalidJID, Message as SMessage + from poezio.tabs.basetabs import OneToOneTab, Tab from poezio import common from poezio import windows from poezio import xhtml -from poezio.common import safeJID -from poezio.config import config +from poezio.config import config, get_image_cache from poezio.core.structs import Command from poezio.decorators import refresh_wrapper from poezio.roster import roster -from poezio.text_buffer import CorrectionError from poezio.theming import get_theme, dump_tuple from poezio.decorators import command_args_parser +from poezio.ui.types import InfoMessage, Message +from poezio.text_buffer import CorrectionError log = logging.getLogger(__name__) class ConversationTab(OneToOneTab): """ - The tab containg a normal conversation (not from a MUC) + The tab containing a normal conversation (not from a MUC) Must not be instantiated, use Static or Dynamic version only. """ - plugin_commands = {} # type: Dict[str, Command] - plugin_keys = {} # type: Dict[str, Callable] - additional_information = {} # type: Dict[str, Callable[[str], str]] + plugin_commands: Dict[str, Command] = {} + plugin_keys: Dict[str, Callable] = {} + additional_information: Dict[str, Callable[[str], str]] = {} message_type = 'chat' - def __init__(self, core, jid): - OneToOneTab.__init__(self, core, jid) + def __init__(self, core, jid, initial=None): + OneToOneTab.__init__(self, core, jid, initial=initial) 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 @@ -73,13 +74,6 @@ class ConversationTab(OneToOneTab): shortdesc='Get the activity.', completion=self.core.completion.last_activity) self.register_command( - 'add', - self.command_add, - desc='Add the current JID to your roster, ask them to' - ' allow you to see his presence, and allow them to' - ' see your presence.', - shortdesc='Add a user to your roster.') - self.register_command( 'invite', self.core.command.impromptu, desc='Invite people into an impromptu room.', @@ -89,13 +83,14 @@ class ConversationTab(OneToOneTab): self.update_keys() @property - def general_jid(self): - return safeJID(self.name).bare + def general_jid(self) -> JID: + return JID(self.jid.bare) def get_info_header(self): raise NotImplementedError @staticmethod + @refresh_wrapper.always def add_information_element(plugin_name, callback): """ Lets a plugin add its own information to the ConversationInfoWin @@ -103,15 +98,95 @@ class ConversationTab(OneToOneTab): ConversationTab.additional_information[plugin_name] = callback @staticmethod + @refresh_wrapper.always def remove_information_element(plugin_name): del ConversationTab.additional_information[plugin_name] def completion(self): self.complete_commands(self.input) + async def handle_message(self, message: SMessage, display: bool = True): + """Handle a received message. + + The message can come from us (carbon copy). + """ + + # Prevent messages coming from our own devices (1:1) to be reflected + if message['to'].bare == self.core.xmpp.boundjid.bare and \ + message['from'].bare == self.core.xmpp.boundjid.bare: + _, index = self._text_buffer._find_message(message['id']) + if index != -1: + return + + use_xhtml = config.get_by_tabname( + 'enable_xhtml_im', + message['from'].bare + ) + tmp_dir = get_image_cache() + + # normal message, we are the recipient + if message['to'].bare == self.core.xmpp.boundjid.bare: + conv_jid = message['from'] + jid = conv_jid + color = get_theme().COLOR_REMOTE_USER + self.last_remote_message = datetime.now() + remote_nick = self.get_nick() + # we wrote the message (happens with carbons) + elif message['from'].bare == self.core.xmpp.boundjid.bare: + conv_jid = message['to'] + jid = self.core.xmpp.boundjid + color = get_theme().COLOR_OWN_NICK + remote_nick = self.core.own_nick + # we are not part of that message, drop it + else: + return + + await self.core.events.trigger_async('conversation_msg', message, self) + + if not message['body']: + return + body = xhtml.get_body_from_message_stanza( + message, use_xhtml=use_xhtml, extract_images_to=tmp_dir) + delayed, date = common.find_delayed_tag(message) + + replaced = False + if message.get_plugin('replace', check=True): + replaced_id = message['replace']['id'] + if replaced_id and config.get_by_tabname('group_corrections', + conv_jid.bare): + try: + replaced = self.modify_message( + body, + replaced_id, + message['id'], + time=date, + jid=jid, + nickname=remote_nick) + except CorrectionError: + log.debug('Unable to correct the message: %s', message) + if not replaced: + msg = Message( + txt=body, + time=date, + nickname=remote_nick, + nick_color=color, + history=delayed, + identifier=message['id'], + jid=jid, + ) + if display: + self.add_message(msg) + else: + self.log_message(msg) + + @refresh_wrapper.always @command_args_parser.raw - def command_say(self, line, attention=False, correct=False): - msg = self.core.xmpp.make_message(self.get_dest_jid()) + async def command_say(self, line: str, attention: bool = False, correct: bool = False): + await self._initial_log.wait() + msg: SMessage = self.core.xmpp.make_message( + mto=self.get_dest_jid(), + mfrom=self.core.xmpp.boundjid + ) msg['type'] = 'chat' msg['body'] = line if not self.nick_sent: @@ -123,24 +198,9 @@ class ConversationTab(OneToOneTab): # 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 CorrectionError: - log.error('Unable to correct a message', exc_info=True) + msg['replace']['id'] = self.last_sent_message['id'] # type: ignore else: del msg['replace'] if msg['body'].find('\x19') != -1: @@ -148,31 +208,21 @@ class ConversationTab(OneToOneTab): 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): - needed = 'inactive' if self.inactive else 'active' - msg['chat_state'] = needed + if self.inactive: + self.send_chat_state('inactive', always_send=True) + else: + msg['chat_state'] = 'active' if 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 - msg._add_receipt = True + self.set_last_sent_message(msg, correct=correct) + msg._add_receipt = True # type: ignore msg.send() + await self.core.handler.on_normal_message(msg) + # Our receipts slixmpp hack self.cancel_paused_delay() - self.text_win.refresh() - self.input.refresh() @command_args_parser.quoted(0, 1) def command_last_activity(self, args): @@ -196,7 +246,13 @@ class ConversationTab(OneToOneTab): 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: + user = '' + try: + user = JID(from_).user + except InvalidJID: + pass + + if not user: msg = '\x19%s}The uptime of %s is %s.' % ( dump_tuple(get_theme().COLOR_INFORMATION_TEXT), from_, common.parse_secs_to_str(seconds)) @@ -205,10 +261,10 @@ class ConversationTab(OneToOneTab): dump_tuple(get_theme().COLOR_INFORMATION_TEXT), from_, common.parse_secs_to_str(seconds), - (' and his/her last status was %s' % status) + (' and their last status was %s' % status) if status else '', ) - self.add_message(msg) + self.add_message(InfoMessage(msg)) self.core.refresh_window() self.core.xmpp.plugin['xep_0012'].get_last_activity( @@ -218,7 +274,10 @@ class ConversationTab(OneToOneTab): @command_args_parser.ignored def command_info(self): contact = roster[self.get_dest_jid()] - jid = safeJID(self.get_dest_jid()) + try: + jid = JID(self.get_dest_jid()) + except InvalidJID: + jid = JID('') if contact: if jid.resource: resource = contact[jid.full] @@ -227,48 +286,29 @@ class ConversationTab(OneToOneTab): 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.presence 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)}) + status = (f', Status: {resource.status}') if resource.status else '' + show = f"Show: {resource.presence or 'available'}" + self.add_message(InfoMessage(f'{show}{status}')) return True + self.add_message( + InfoMessage("No information available"), + ) + return True @command_args_parser.quoted(0, 1) - def command_version(self, args): + async def command_version(self, args): """ /version [jid] """ if args: - return self.core.command.version(args[0]) - jid = safeJID(self.name) + return await self.core.command.version(args[0]) + jid = self.jid if not jid.resource: if jid in roster: resource = roster[jid].get_highest_priority_resource() jid = resource.jid if resource else jid - self.core.xmpp.plugin['xep_0092'].get_version( - jid, callback=self.core.handler.on_version_result) - - @command_args_parser.ignored - def command_add(self): - """ - Add the current JID to the roster, and automatically - accept the reverse subscription - """ - jid = self.general_jid - 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') + iq = await self.core.xmpp.plugin['xep_0092'].get_version(jid) + self.core.handler.on_version_result(iq) def resize(self): self.need_resize = False @@ -285,8 +325,10 @@ class ConversationTab(OneToOneTab): 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) + self.width, bar_height, 0, self._text_buffer, + force=self.ui_config_changed + ) + self.ui_config_changed = False if display_bar: self.upper_bar.resize(1, self.width, 0, 0) self.get_info_header().resize( @@ -321,14 +363,13 @@ class ConversationTab(OneToOneTab): self.input.refresh() def get_nick(self): - jid = safeJID(self.name) - contact = roster[jid.bare] + contact = roster[self.jid.bare] if contact: - return contact.name or jid.user + return contact.name or self.jid.user else: if self.nick: return self.nick - return jid.user + return self.jid.user or self.jid.domain def on_input(self, key, raw): if not raw and key in self.key_func: @@ -343,7 +384,10 @@ class ConversationTab(OneToOneTab): def on_lose_focus(self): contact = roster[self.get_dest_jid()] - jid = safeJID(self.get_dest_jid()) + try: + jid = JID(self.get_dest_jid()) + except InvalidJID: + jid = JID('') if contact: if jid.resource: resource = contact[jid.full] @@ -364,7 +408,10 @@ class ConversationTab(OneToOneTab): def on_gain_focus(self): contact = roster[self.get_dest_jid()] - jid = safeJID(self.get_dest_jid()) + try: + jid = JID(self.get_dest_jid()) + except InvalidJID: + jid = JID('') if contact: if jid.resource: resource = contact[jid.full] @@ -391,9 +438,6 @@ class ConversationTab(OneToOneTab): 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): @@ -401,7 +445,7 @@ class ConversationTab(OneToOneTab): def matching_names(self): res = [] - jid = safeJID(self.name) + jid = self.jid res.append((2, jid.bare)) res.append((1, jid.user)) contact = roster[self.name] @@ -417,13 +461,13 @@ class DynamicConversationTab(ConversationTab): bad idea so it has been removed. Only one DynamicConversationTab can be opened for a given jid. """ - plugin_commands = {} # type: Dict[str, Command] - plugin_keys = {} # type: Dict[str, Callable] + plugin_commands: Dict[str, Command] = {} + plugin_keys: Dict[str, Callable] = {} - def __init__(self, core, jid, resource=None): + def __init__(self, core, jid, initial=None): self.locked_resource = None - self.name = safeJID(jid).bare - ConversationTab.__init__(self, core, jid) + ConversationTab.__init__(self, core, jid, initial=initial) + self.jid.resource = None self.info_header = windows.DynamicConversationInfoWin() self.register_command( 'unlock', self.unlock_command, shortdesc='Deprecated, do nothing.') @@ -447,7 +491,7 @@ class DynamicConversationTab(ConversationTab): """ Returns the bare jid. """ - return self.name + return self.jid.bare def refresh(self): """ @@ -460,9 +504,9 @@ class DynamicConversationTab(ConversationTab): self.text_win.refresh() if display_bar: - self.upper_bar.refresh(self.name, roster[self.name]) - displayed_jid = self.name - self.get_info_header().refresh(displayed_jid, roster[self.name], + self.upper_bar.refresh(self.jid.bare, roster[self.jid.bare]) + displayed_jid = self.jid.bare + self.get_info_header().refresh(displayed_jid, roster[self.jid.bare], self.text_win, self.chatstate, ConversationTab.additional_information) if display_info_win: @@ -475,8 +519,8 @@ class DynamicConversationTab(ConversationTab): """ Different from the parent class only for the info_header object. """ - displayed_jid = self.name - self.get_info_header().refresh(displayed_jid, roster[self.name], + displayed_jid = self.jid.bare + self.get_info_header().refresh(displayed_jid, roster[self.jid.bare], self.text_win, self.chatstate, ConversationTab.additional_information) self.input.refresh() @@ -487,16 +531,20 @@ class StaticConversationTab(ConversationTab): A conversation tab associated with one Full JID. It cannot be locked to an different resource or unlocked. """ - plugin_commands = {} # type: Dict[str, Command] - plugin_keys = {} # type: Dict[str, Callable] + plugin_commands: Dict[str, Command] = {} + plugin_keys: Dict[str, Callable] = {} - def __init__(self, core, jid): - assert (safeJID(jid).resource) - ConversationTab.__init__(self, core, jid) + def __init__(self, core, jid, initial=None): + ConversationTab.__init__(self, core, jid, initial=initial) + assert jid.resource self.info_header = windows.ConversationInfoWin() self.resize() self.update_commands() self.update_keys() + async def init_logs(self, initial=None) -> None: + # Disable local logs because… + pass + def get_info_header(self): return self.info_header diff --git a/poezio/tabs/data_forms.py b/poezio/tabs/data_forms.py index 496863bc..8e13a84c 100644 --- a/poezio/tabs/data_forms.py +++ b/poezio/tabs/data_forms.py @@ -14,11 +14,11 @@ log = logging.getLogger(__name__) class DataFormsTab(Tab): """ - A tab contaning various window type, displaying + A tab containing various window type, displaying a form that the user needs to fill. """ - plugin_commands = {} # type: Dict[str, Command] - plugin_keys = {} # type: Dict[str, Callable] + plugin_commands: Dict[str, Command] = {} + plugin_keys: Dict[str, Callable] = {} def __init__(self, core, form, on_cancel, on_send, kwargs): Tab.__init__(self, core) diff --git a/poezio/tabs/listtab.py b/poezio/tabs/listtab.py index 07b3fe05..049f7076 100644 --- a/poezio/tabs/listtab.py +++ b/poezio/tabs/listtab.py @@ -1,5 +1,5 @@ """ -A generic tab that displays a serie of items in a scrollable, searchable, +A generic tab that displays a series 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. """ @@ -18,8 +18,8 @@ log = logging.getLogger(__name__) class ListTab(Tab): - plugin_commands = {} # type: Dict[str, Command] - plugin_keys = {} # type: Dict[str, Callable] + plugin_commands: Dict[str, Command] = {} + plugin_keys: Dict[str, Callable] = {} def __init__(self, core, name, help_message, header_text, cols): """Parameters: @@ -34,7 +34,7 @@ class ListTab(Tab): Tab.__init__(self, core) self.state = 'normal' self._error_message = '' - self.name = name + self._name = name columns = collections.OrderedDict() for col, num in cols: columns[col] = num diff --git a/poezio/tabs/muclisttab.py b/poezio/tabs/muclisttab.py index aac25787..53fce727 100644 --- a/poezio/tabs/muclisttab.py +++ b/poezio/tabs/muclisttab.py @@ -4,6 +4,7 @@ A MucListTab is a tab listing the rooms on a conference server. It has no functionality except scrolling the list, and allowing the user to join the rooms. """ +import asyncio import logging from typing import Dict, Callable @@ -20,8 +21,8 @@ class MucListTab(ListTab): A tab listing rooms from a specific server, displaying various information, scrollable, and letting the user join them, etc """ - plugin_commands = {} # type: Dict[str, Command] - plugin_keys = {} # type: Dict[str, Callable] + plugin_commands: Dict[str, Command] = {} + plugin_keys: Dict[str, Callable] = {} def __init__(self, core, server): ListTab.__init__(self, core, server.full, "“j”: join room.", @@ -60,6 +61,7 @@ class MucListTab(ListTab): items = [(item[0].split('@')[0], item[0], item[2] or '', '') for item in get_items()] + items = sorted(items, key=lambda item: item[0]) self.listview.set_lines(items) self.info_header.message = 'Chatroom list on server %s' % self.name if self.core.tabs.current_tab is self: @@ -73,4 +75,4 @@ class MucListTab(ListTab): row = self.listview.get_selected_row() if not row: return - self.core.command.join(row[1]) + asyncio.ensure_future(self.core.command.join(row[1])) diff --git a/poezio/tabs/muctab.py b/poezio/tabs/muctab.py index d533f817..e2d546c9 100644 --- a/poezio/tabs/muctab.py +++ b/poezio/tabs/muctab.py @@ -7,6 +7,9 @@ It keeps track of many things such as part/joins, maintains an user list, and updates private tabs when necessary. """ +from __future__ import annotations + +import asyncio import bisect import curses import logging @@ -14,77 +17,114 @@ import os import random import re import functools +from copy import copy +from dataclasses import dataclass from datetime import datetime -from typing import Dict, Callable, List, Optional, Union, Set - -from slixmpp import JID +from typing import ( + cast, + Any, + Dict, + Callable, + List, + Optional, + Tuple, + Union, + Set, + Type, + Pattern, + TYPE_CHECKING, +) + +from slixmpp import InvalidJID, JID, Presence, Iq, Message as SMessage +from slixmpp.exceptions import IqError, IqTimeout from poezio.tabs import ChatTab, Tab, SHOW_NAME from poezio import common -from poezio import fixes from poezio import multiuserchat as muc from poezio import timed_events from poezio import windows from poezio import xhtml -from poezio.common import safeJID -from poezio.config import config +from poezio.common import to_utc +from poezio.config import config, get_image_cache from poezio.core.structs import Command from poezio.decorators import refresh_wrapper, command_args_parser from poezio.logger import logger +from poezio.log_loader import LogLoader, MAMFiller from poezio.roster import roster +from poezio.text_buffer import CorrectionError from poezio.theming import get_theme, dump_tuple from poezio.user import User from poezio.core.structs import Completion, Status +from poezio.ui.types import ( + BaseMessage, + InfoMessage, + Message, + MucOwnJoinMessage, + MucOwnLeaveMessage, + PersistentInfoMessage, +) + +if TYPE_CHECKING: + from poezio.core.core import Core + from slixmpp.plugins.xep_0004 import Form log = logging.getLogger(__name__) NS_MUC_USER = 'http://jabber.org/protocol/muc#user' -STATUS_XPATH = '{%s}x/{%s}status' % (NS_MUC_USER, NS_MUC_USER) COMPARE_USERS_LAST_TALKED = lambda x: x.last_talked +@dataclass +class MessageData: + message: SMessage + delayed: bool + date: Optional[datetime] + nick: str + user: Optional[User] + room_from: str + body: str + is_history: bool + + 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 + It contains a userlist, an input, a topic, an information and a chat zone """ message_type = 'groupchat' - plugin_commands = {} # type: Dict[str, Command] - plugin_keys = {} # type: Dict[str, Callable] - additional_information = {} # type: Dict[str, Callable[[str], str]] - lagged = False + plugin_commands: Dict[str, Command] = {} + plugin_keys: Dict[str, Callable[..., Any]] = {} + additional_information: Dict[str, Callable[[str], str]] = {} + lagged: bool = False - def __init__(self, core, jid, nick, password=None): + def __init__(self, core: Core, jid: JID, nick: str, password: Optional[str] = None) -> None: ChatTab.__init__(self, core, jid) self.joined = False self._state = 'disconnected' # our nick in the MUC self.own_nick = nick # self User object - self.own_user = None # type: Optional[User] - self.name = jid + self.own_user: Optional[User] = None self.password = password # buffered presences - self.presence_buffer = [] + self.presence_buffer: List[Presence] = [] # userlist - self.users = [] # type: List[User] + self.users: List[User] = [] # private conversations - self.privates = [] # type: List[Tab] + self.privates: List[Tab] = [] self.topic = '' self.topic_from = '' # Self ping event, so we can cancel it when we leave the room - self.self_ping_event = None + self.self_ping_event: Optional[timed_events.DelayedEvent] = None # UI stuff 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.input: windows.MessageInput = windows.MessageInput() # List of ignored users - self.ignores = [] # type: List[User] + self.ignores: List[User] = [] # keys self.register_keys() self.update_keys() @@ -94,8 +134,8 @@ class MucTab(ChatTab): self.resize() @property - def general_jid(self): - return self.name + def general_jid(self) -> JID: + return self.jid def check_send_chat_state(self) -> bool: "If we should send a chat state" @@ -109,6 +149,7 @@ class MucTab(ChatTab): return None @staticmethod + @refresh_wrapper.always def add_information_element(plugin_name: str, callback: Callable[[str], str]) -> None: """ Lets a plugin add its own information to the MucInfoWin @@ -116,54 +157,65 @@ class MucTab(ChatTab): MucTab.additional_information[plugin_name] = callback @staticmethod + @refresh_wrapper.always def remove_information_element(plugin_name: str) -> None: """ Lets a plugin add its own information to the MucInfoWin """ del MucTab.additional_information[plugin_name] - def cancel_config(self, form): + def cancel_config(self, form: Form) -> None: """ - The user do not want to send his/her config, send an iq cancel + The user do not want to send their config, send an iq cancel """ - muc.cancel_config(self.core.xmpp, self.name) + asyncio.create_task(self.core.xmpp['xep_0045'].cancel_config(self.jid)) self.core.close_tab() - def send_config(self, form): + def send_config(self, form: Form) -> None: """ - The user sends his/her config to the server + The user sends their config to the server """ - muc.configure_room(self.core.xmpp, self.name, form) + asyncio.create_task(self.core.xmpp['xep_0045'].set_room_config(self.jid, form)) self.core.close_tab() - def join(self): + def join(self) -> None: """ Join the room """ + seconds: Optional[int] status = self.core.get_status() if self.last_connection: - delta = datetime.now() - self.last_connection + delta = to_utc(datetime.now()) - to_utc(self.last_connection) seconds = delta.seconds + delta.days * 24 * 3600 else: + last_message = self._text_buffer.find_last_message() seconds = None + if last_message is not None: + seconds = (datetime.now() - last_message.time).seconds + use_log = config.get_by_tabname('mam_sync', self.general_jid) + mam_sync = config.get_by_tabname('mam_sync', self.general_jid) + if self.mam_filler is None and use_log and mam_sync: + limit = config.get_by_tabname('mam_sync_limit', self.jid) + self.mam_filler = MAMFiller(logger, self, limit) muc.join_groupchat( self.core, - self.name, + self.jid, self.own_nick, - self.password, + self.password or '', status=status.message, show=status.show, seconds=seconds) - def leave_room(self, message: str): + def leave_room(self, message: str) -> 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) + theme = get_theme() + info_col = dump_tuple(theme.COLOR_INFORMATION_TEXT) + char_quit = theme.CHAR_QUIT + spec_col = dump_tuple(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) + color = dump_tuple(theme.COLOR_OWN_NICK) else: color = "3" @@ -189,76 +241,103 @@ class MucTab(ChatTab): 'color_spec': spec_col, 'nick': self.own_nick, } - - self.add_message(msg, typ=2) + self.add_message(MucOwnLeaveMessage(msg)) self.disconnect() - muc.leave_groupchat(self.core.xmpp, self.name, self.own_nick, + muc.leave_groupchat(self.core.xmpp, self.jid, self.own_nick, message) - self.core.disable_private_tabs(self.name, reason=msg) + self.core.disable_private_tabs(self.jid.bare, reason=msg) else: - muc.leave_groupchat(self.core.xmpp, self.name, self.own_nick, + self.presence_buffer = [] + self.users = [] + muc.leave_groupchat(self.core.xmpp, self.jid, self.own_nick, message) - def change_affiliation(self, - nick_or_jid: Union[str, JID], - affiliation: str, - reason=''): + async def change_affiliation( + self, + nick_or_jid: Union[str, JID], + affiliation: str, + reason: str = '' + ) -> None: """ Change the affiliation of a nick or JID """ - - def callback(iq): - if iq['type'] == 'error': - self.core.information( - "Could not set affiliation '%s' for '%s'." % - (affiliation, nick_or_jid), "Warning") - if not self.joined: return valid_affiliations = ('outcast', 'none', 'member', 'admin', 'owner') if affiliation not in valid_affiliations: - return self.core.information( + self.core.information( 'The affiliation must be one of ' + ', '.join(valid_affiliations), 'Error') - if nick_or_jid in [user.nick for user in self.users]: - muc.set_user_affiliation( - self.core.xmpp, - self.name, - affiliation, - nick=nick_or_jid, - callback=callback, - reason=reason) - else: - muc.set_user_affiliation( - self.core.xmpp, - self.name, - affiliation, - jid=safeJID(nick_or_jid), - callback=callback, - reason=reason) + return + jid = None + nick = None + for user in self.users: + if user.nick == nick_or_jid: + jid = user.jid + nick = user.nick + break + if jid is None: + try: + jid = JID(nick_or_jid) + except InvalidJID: + self.core.information( + f'Invalid JID or missing occupant: {nick_or_jid}', + 'Error' + ) + return - def change_role(self, nick: str, role: str, reason=''): + try: + if affiliation != 'member': + nick = None + await self.core.xmpp['xep_0045'].set_affiliation( + self.jid, + jid=jid, + nick=nick, + affiliation=affiliation, + reason=reason + ) + self.core.information( + f"Affiliation of {jid} set to {affiliation} successfully", + "Info" + ) + except (IqError, IqTimeout) as exc: + self.core.information( + f"Could not set affiliation '{affiliation}' for '{jid}': {exc}", + "Warning", + ) + + async def change_role(self, nick: str, role: str, reason: str = '') -> None: """ Change the role of a nick """ - def callback(iq): - if iq['type'] == 'error': - self.core.information( - "Could not set role '%s' for '%s'." % (role, nick), - "Warning") - valid_roles = ('none', 'visitor', 'participant', 'moderator') if not self.joined or role not in valid_roles: - return self.core.information( + self.core.information( 'The role must be one of ' + ', '.join(valid_roles), 'Error') + return + + try: + target_jid = copy(self.jid) + target_jid.resource = nick + except InvalidJID: + self.core.information('Invalid nick', 'Info') + return - 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) + try: + await self.core.xmpp['xep_0045'].set_role( + self.jid, nick, role=role, reason=reason + ) + self.core.information( + f'Role of {nick} changed to {role} successfully.' + 'Info' + ) + except (IqError, IqTimeout) as e: + self.core.information( + "Could not set role '%s' for '%s': %s" % (role, nick, e), + "Warning") @refresh_wrapper.conditional def print_info(self, nick: str) -> bool: @@ -289,20 +368,21 @@ class MucTab(ChatTab): 'role': user.role or 'None', 'status': '\n%s' % user.status if user.status else '' } - self.add_message(info, typ=0) + self.add_message(InfoMessage(info)) return True - def change_topic(self, topic: str): + def change_topic(self, topic: str) -> None: """Change the current topic""" - muc.change_subject(self.core.xmpp, self.name, topic) + self.core.xmpp.plugin['xep_0045'].set_subject(self.jid, topic) @refresh_wrapper.always - def show_topic(self): + def show_topic(self) -> None: """ Print the current topic """ - info_text = dump_tuple(get_theme().COLOR_INFORMATION_TEXT) - norm_text = dump_tuple(get_theme().COLOR_NORMAL_TEXT) + theme = get_theme() + info_text = dump_tuple(theme.COLOR_INFORMATION_TEXT) + norm_text = dump_tuple(theme.COLOR_NORMAL_TEXT) if self.topic_from: user = self.get_user_by_name(self.topic_from) if user: @@ -314,42 +394,23 @@ class MucTab(ChatTab): else: user_string = '' - self._text_buffer.add_message( - "\x19%s}The subject of the room is: \x19%s}%s %s" % - (info_text, norm_text, self.topic, user_string)) + self.add_message( + InfoMessage( + "The subject of the room is: \x19%s}%s %s" % + (norm_text, self.topic, user_string), + ), + ) @refresh_wrapper.always - def recolor(self, random_colors=False): + def recolor(self) -> None: """Recolor the current MUC users""" - deterministic = config.get_by_tabname('deterministic_nick_colors', - self.name) - if deterministic: - for user in self.users: - if user is self.own_user: - continue - color = self.search_for_color(user.nick) - if color != '': - continue - user.set_deterministic_color() - return - # Sort the user list by last talked, to avoid color conflicts - # on active participants - sorted_users = sorted(self.users, key=COMPARE_USERS_LAST_TALKED, 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) + for user in self.users: if user is self.own_user: - sorted_users.remove(user) - elif color != '': - sorted_users.remove(user) - user.change_color(color, deterministic) - colors = list(get_theme().LIST_COLOR_NICKNAMES) - if random_colors: - random.shuffle(colors) - for i, user in enumerate(sorted_users): - user.color = colors[i % len(colors)] + continue + color = self.search_for_color(user.nick) + if color != '': + continue + user.set_deterministic_color() self.text_win.rebuild_everything(self._text_buffer) @refresh_wrapper.conditional @@ -371,7 +432,7 @@ class MucTab(ChatTab): user.change_color(color) config.set_and_save(nick, color, 'muc_colors') nick_color_aliases = config.get_by_tabname('nick_color_aliases', - self.name) + self.jid) if nick_color_aliases: # if any user in the room has a nick which is an alias of the # nick, update its color @@ -384,7 +445,7 @@ class MucTab(ChatTab): self.text_win.rebuild_everything(self._text_buffer) return True - def on_input(self, key, raw): + def on_input(self, key: str, raw: bool) -> bool: if not raw and key in self.key_func: self.key_func[key]() return False @@ -397,18 +458,15 @@ class MucTab(ChatTab): return False def get_nick(self) -> str: - if config.get('show_muc_jid'): - return self.name - bookmark = self.core.bookmarks[self.name] + if config.getbool('show_muc_jid'): + return cast(str, self.jid) + bookmark = self.core.bookmarks[self.jid] if bookmark is not None and bookmark.name: return bookmark.name # TODO: send the disco#info identity name here, if it exists. - return safeJID(self.name).user - - def get_text_window(self): - return self.text_win + return self.jid.node - def on_lose_focus(self): + def on_lose_focus(self) -> None: if self.joined: if self.input.text: self.state = 'nonempty' @@ -424,10 +482,10 @@ class MucTab(ChatTab): self.send_chat_state('inactive') self.check_scrolled() - def on_gain_focus(self): + def on_gain_focus(self) -> None: 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')): + and not config.getbool('show_useless_separator')): self.text_win.remove_line_separator() curses.curs_set(1) if self.joined and config.get_by_tabname( @@ -435,19 +493,136 @@ class MucTab(ChatTab): self.general_jid) and not self.input.get_text(): self.send_chat_state('active') - def handle_presence(self, presence): + async def handle_message(self, message: SMessage) -> bool: + """Parse an incoming message + + Returns False if the message was dropped silently. """ - Handle MUC presence + room_from = message['from'].bare + nick_from = message['mucnick'] + user = self.get_user_by_name(nick_from) + if user and user in self.ignores: + return False + + await self.core.events.trigger_async('muc_msg', message, self) + use_xhtml = config.get_by_tabname('enable_xhtml_im', room_from) + tmp_dir = get_image_cache() + body = xhtml.get_body_from_message_stanza( + message, use_xhtml=use_xhtml, extract_images_to=tmp_dir) + + # TODO: #3314. Is this a MUC reflection? + # Is this an encrypted message? Is so ignore. + # It is not possible in the OMEMO case to decrypt these messages + # since we don't encrypt for our own device (something something + # forward secrecy), but even for non-FS encryption schemes anyway + # messages shouldn't have changed after a round-trip to the room. + # Otherwire replace the matching message we sent. + if not body: + return False + + old_state = self.state + delayed, date = common.find_delayed_tag(message) + is_history = not self.joined and delayed + + mdata = MessageData( + message, delayed, date, nick_from, user, room_from, body, + is_history + ) + + replaced = False + if message.xml.find('{urn:xmpp:message-correct:0}replace') is not None: + replaced = await self._handle_correction_message(mdata) + if not replaced: + await self._handle_normal_message(mdata) + if mdata.nick == self.own_nick: + self.set_last_sent_message(message, correct=replaced) + self._refresh_after_message(old_state) + return True + + def _refresh_after_message(self, old_state: str) -> None: + """Refresh the appropriate UI after a message is received""" + if self is self.core.tabs.current_tab: + self.refresh() + elif self.state != old_state: + self.core.refresh_tab_win() + current = self.core.tabs.current_tab + current.refresh_input() + self.core.doupdate() + + async def _handle_correction_message(self, message: MessageData) -> bool: + """Process a correction message. + + Returns true if a message was actually corrected. """ + replaced_id = message.message['replace']['id'] + if replaced_id != '' and config.get_by_tabname( + 'group_corrections', JID(message.room_from)): + try: + delayed_date = message.date or datetime.now() + modify_hl = self.modify_message( + message.body, + replaced_id, + message.message['id'], + time=delayed_date, + delayed=message.delayed, + nickname=message.nick, + user=message.user + ) + if modify_hl: + await self.core.events.trigger_async( + 'highlight', + message.message, + self + ) + return True + except CorrectionError: + log.debug('Unable to correct a message', exc_info=True) + return False + + async def _handle_normal_message(self, message: MessageData) -> None: + """ + Process the non-correction groupchat message. + """ + ui_msg: Union[InfoMessage, Message] + # Messages coming from MUC barejid (Server maintenance, IRC mode + # changes from biboumi, etc.) have no nick/resource and are displayed + # as info messages. + highlight = False + if message.nick: + highlight = self.message_is_highlight( + message.body, message.nick, message.is_history + ) + ui_msg = Message( + txt=message.body, + time=message.date, + nickname=message.nick, + history=message.is_history, + delayed=message.delayed, + identifier=message.message['id'], + jid=message.message['from'], + user=message.user, + highlight=highlight, + ) + else: + ui_msg = InfoMessage( + txt=message.body, + time=message.date, + identifier=message.message['id'], + ) + self.add_message(ui_msg) + if highlight: + await self.core.events.trigger_async('highlight', message, self) + + def handle_presence(self, presence: Presence) -> None: + """Handle MUC presence""" self.reset_lag() - status_codes = set() - for status_code in presence.xml.findall(STATUS_XPATH): - status_codes.add(status_code.attrib['code']) + status_codes = presence['muc']['status_codes'] if presence['type'] == 'error': - self.core.room_error(presence, self.name) + self.core.room_error(presence, self.jid.bare) elif not self.joined: - if '110' in status_codes or self.own_nick == presence['from'].resource: - self.process_presence_buffer(presence) + own = 110 in status_codes + if own or len(self.presence_buffer) >= 10: + self.process_presence_buffer(presence, own) else: self.presence_buffer.append(presence) return @@ -465,63 +640,64 @@ class MucTab(ChatTab): self.input.refresh() self.core.doupdate() - def process_presence_buffer(self, last_presence): + def process_presence_buffer(self, last_presence: Presence, own: bool) -> None: """ Batch-process all the initial presences """ - deterministic = config.get_by_tabname('deterministic_nick_colors', - self.name) - for stanza in self.presence_buffer: try: - self.handle_presence_unjoined(stanza, deterministic) + self.handle_presence_unjoined(stanza) except PresenceError: self.core.room_error(stanza, stanza['from'].bare) - self.handle_presence_unjoined(last_presence, deterministic, own=True) + self.presence_buffer = [] + self.handle_presence_unjoined(last_presence, own) self.users.sort() # Enable the self ping event, to regularly check if we # are still in the room. - self.enable_self_ping_event() + if own: + self.enable_self_ping_event() if self.core.tabs.current_tab is not self: self.refresh_tab_win() self.core.tabs.current_tab.refresh_input() self.core.doupdate() - def handle_presence_unjoined(self, presence, deterministic, own=False): + def handle_presence_unjoined(self, presence: Presence, own: bool = False) -> None: """ Presence received while we are not in the room (before code=110) """ - from_nick, _, affiliation, show, status, role, jid, typ = dissect_presence( - presence) + # If presence is coming from MUC barejid, ignore. + if not presence['from'].resource: + return None + dissected_presence = dissect_presence(presence) + from_nick, _, affiliation, show, status, role, jid, typ = dissected_presence if typ == 'unavailable': return user_color = self.search_for_color(from_nick) new_user = User(from_nick, affiliation, show, status, role, jid, - deterministic, user_color) + user_color) self.users.append(new_user) self.core.events.trigger('muc_join', presence, self) if own: - status_codes = set() - for status_code in presence.xml.findall(STATUS_XPATH): - status_codes.add(status_code.attrib['code']) + status_codes = presence['muc']['status_codes'] self.own_join(from_nick, new_user, status_codes) - def own_join(self, from_nick: str, new_user: User, status_codes: Set[str]): + def own_join(self, from_nick: str, new_user: User, status_codes: Set[int]) -> None: """ Handle the last presence we received, entering the room """ self.own_nick = from_nick self.own_user = new_user self.joined = True - if self.name in self.core.initial_joins: - self.core.initial_joins.remove(self.name) + if self.jid in self.core.initial_joins: + self.core.initial_joins.remove(self.jid) self._state = 'normal' elif self != self.core.tabs.current_tab: self._state = 'joined' if (self.core.tabs.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 + theme = get_theme() + new_user.color = theme.COLOR_OWN_NICK if config.get_by_tabname('display_user_color_in_join_part', self.general_jid): @@ -529,54 +705,63 @@ class MucTab(ChatTab): 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) + info_col = dump_tuple(theme.COLOR_INFORMATION_TEXT) + warn_col = dump_tuple(theme.COLOR_WARNING_TEXT) + spec_col = dump_tuple(theme.COLOR_JOIN_CHAR) enable_message = ('\x19%(color_spec)s}%(spec)s\x19%(info_col)s} You ' '(\x19%(nick_col)s}%(nick)s\x19%(info_col)s}) joined' ' the room') % { 'nick': from_nick, - 'spec': get_theme().CHAR_JOIN, + 'spec': theme.CHAR_JOIN, 'color_spec': spec_col, 'nick_col': color, 'info_col': info_col, } - self.add_message(enable_message, typ=2) - self.core.enable_private_tabs(self.name, enable_message) - if '201' in status_codes: + self.add_message(MucOwnJoinMessage(enable_message)) + self.core.enable_private_tabs(self.jid.bare, enable_message) + 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: + PersistentInfoMessage('Info: The room has been created'), + ) + 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: + InfoMessage( + '\x19%(warn_col)s}Warning:\x19%(info_col)s}' + ' This room is publicly logged' % { + 'info_col': info_col, + 'warn_col': warn_col + } + ), + ) + 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) - - def handle_presence_joined(self, presence, status_codes): + InfoMessage( + '\x19%(warn_col)s}Warning:\x19%(info_col)s}' + ' This room is not anonymous.' % { + 'info_col': info_col, + 'warn_col': warn_col + }, + ), + ) + asyncio.create_task(LogLoader( + logger, self, config.get_by_tabname('use_log', self.general_jid) + ).tab_open()) + + def handle_presence_joined(self, presence: Presence, status_codes: Set[int]) -> None: """ Handle new presences when we are already in the room """ - from_nick, from_room, affiliation, show, status, role, jid, typ = dissect_presence( - presence) - 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' - server_initiated = '333' in status_codes and typ == 'unavailable' - non_member = '322' in status_codes and typ == 'unavailable' + # If presence is coming from MUC barejid, ignore. + if not presence['from'].resource: + return None + dissected_presence = dissect_presence(presence) + from_nick, from_room, affiliation, show, status, role, jid, typ = dissected_presence + 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' + server_initiated = 333 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 and typ != "unavailable": @@ -585,11 +770,11 @@ class MucTab(ChatTab): self.on_user_join(from_nick, affiliation, show, status, role, jid, user_color) elif user is None: - log.error('BUG: User %s in %s is None', from_nick, self.name) + log.error('BUG: User %s in %s is None', from_nick, self.jid) return elif change_nick: self.core.events.trigger('muc_nickchange', presence, self) - self.on_user_nick_change(presence, user, from_nick, from_room) + self.on_user_nick_change(presence, user, from_nick) elif ban: self.core.events.trigger('muc_ban', presence, self) self.core.on_user_left_private_conversation( @@ -609,39 +794,50 @@ class MucTab(ChatTab): # user quit elif typ == 'unavailable': self.on_user_leave_groupchat(user, jid, status, from_nick, - from_room, server_initiated) + JID(from_room), server_initiated) + ns = 'http://jabber.org/protocol/muc#user' + if presence.xml.find(f'{{{ns}}}x/{{{ns}}}destroy') is not None: + info = f'Room {self.jid} was destroyed.' + if presence['muc']['destroy']: + reason = presence['muc']['destroy']['reason'] + altroom = presence['muc']['destroy']['jid'] + if reason: + info += f' “{reason}”.' + if altroom: + info += f' The new address now is {altroom}.' + self.core.information(info, 'Info') # status change else: self.on_user_change_status(user, from_nick, from_room, affiliation, role, show, status) - def on_non_member_kicked(self): + def on_non_member_kicked(self) -> None: """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) + MucOwnLeaveMessage( + 'You have been kicked because you ' + 'are not a member and the room is now members-only.' + ) + ) self.disconnect() - def on_muc_shutdown(self): + def on_muc_shutdown(self) -> None: """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) + MucOwnLeaveMessage( + 'You have been kicked because the' + ' MUC service is shutting down.' + ) + ) self.disconnect() - def on_user_join(self, from_nick, affiliation, show, status, role, jid, - color): + def on_user_join(self, from_nick: str, affiliation: str, show: str, status: str, role: str, jid: JID, + color: str) -> None: """ 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) + color) bisect.insort_left(self.users, user) hide_exit_join = config.get_by_tabname('hide_exit_join', self.general_jid) @@ -650,10 +846,11 @@ class MucTab(ChatTab): 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 + color = "3" + theme = get_theme() + info_col = dump_tuple(theme.COLOR_INFORMATION_TEXT) + spec_col = dump_tuple(theme.COLOR_JOIN_CHAR) + char_join = 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 room') % { @@ -672,16 +869,17 @@ class MucTab(ChatTab): 'color': color, 'jid': jid.full, 'info_col': info_col, - 'jid_color': dump_tuple(get_theme().COLOR_MUC_JID), + 'jid_color': dump_tuple(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.xml.find( - '{%s}x/{%s}item' % (NS_MUC_USER, NS_MUC_USER)).attrib['nick'] - old_color = user.color + self.add_message(PersistentInfoMessage(msg)) + self.core.on_user_rejoined_private_conversation(self.jid.bare, from_nick) + + def on_user_nick_change(self, presence: Presence, user: User, from_nick: str) -> None: + new_nick = presence['muc']['item']['nick'] + if not new_nick: + return # should not happen + old_color_tuple = user.color if user.nick == self.own_nick: self.own_nick = new_nick # also change our nick in all private discussions of this room @@ -689,57 +887,56 @@ class MucTab(ChatTab): user.change_nick(new_nick) else: user.change_nick(new_nick) - deterministic = config.get_by_tabname('deterministic_nick_colors', - self.name) - color = config.get_by_tabname(new_nick, 'muc_colors') or None - if color or deterministic: - user.change_color(color, deterministic) + color = config.getstr(new_nick, section='muc_colors') or None + user.change_color(color) 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) - old_color = dump_tuple(old_color) + old_color = dump_tuple(old_color_tuple) else: - old_color = color = 3 + old_color = color = "3" info_col = dump_tuple(get_theme().COLOR_INFORMATION_TEXT) self.add_message( - '\x19%(old_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, - 'old_color': old_color, - 'info_col': info_col - }, - typ=2) + PersistentInfoMessage( + '\x19%(old_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, + 'old_color': old_color, + 'info_col': info_col + }, + ) + ) # rename the private tabs if needed - self.core.rename_private_tabs(self.name, from_nick, user) + self.core.rename_private_tabs(self.jid.bare, from_nick, user) - def on_user_banned(self, presence, user, from_nick): + def on_user_banned(self, presence: Presence, user: User, from_nick: str) -> None: """ When someone is banned from a muc """ + cls: Type[InfoMessage] = PersistentInfoMessage self.users.remove(user) - by = presence.xml.find('{%s}x/{%s}item/{%s}actor' % - (NS_MUC_USER, NS_MUC_USER, NS_MUC_USER)) - reason = presence.xml.find('{%s}x/{%s}item/{%s}reason' % - (NS_MUC_USER, NS_MUC_USER, NS_MUC_USER)) - if by: - by = by.get('jid') or by.get('nick') or None - else: - by = None + by = presence['muc']['item'].get_plugin('actor', check=True) + reason = presence['muc']['item']['reason'] + by_repr: Union[JID, str, None] = None + if by is not None: + by_repr = by['jid'] or by['nick'] or None - info_col = dump_tuple(get_theme().COLOR_INFORMATION_TEXT) - char_kick = get_theme().CHAR_KICK + theme = get_theme() + info_col = dump_tuple(theme.COLOR_INFORMATION_TEXT) + char_kick = theme.CHAR_KICK if from_nick == self.own_nick: # we are banned + cls = MucOwnLeaveMessage 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, + 'by': by_repr, 'info_col': info_col } else: @@ -748,7 +945,7 @@ class MucTab(ChatTab): 'spec': char_kick, 'info_col': info_col } - self.core.disable_private_tabs(self.name, reason=kick_msg) + self.core.disable_private_tabs(self.jid.bare, reason=kick_msg) self.disconnect() self.refresh_tab_win() self.core.tabs.current_tab.refresh_input() @@ -757,11 +954,11 @@ class MucTab(ChatTab): self.general_jid) delay = common.parse_str_to_secs(delay) if delay <= 0: - muc.join_groupchat(self.core, self.name, self.own_nick) + muc.join_groupchat(self.core, self.jid, self.own_nick) else: self.core.add_timed_event( timed_events.DelayedEvent(delay, muc.join_groupchat, - self.core, self.name, + self.core, self.jid, self.own_nick)) else: @@ -769,16 +966,16 @@ class MucTab(ChatTab): self.general_jid): color = dump_tuple(user.color) else: - color = 3 + color = "3" - if by: + if by_repr: 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, + 'by': by_repr, 'info_col': info_col } else: @@ -789,29 +986,30 @@ class MucTab(ChatTab): 'color': color, 'info_col': info_col } - if reason is not None and reason.text: + if reason: kick_msg += ('\x19%(info_col)s} Reason: \x196}' '%(reason)s\x19%(info_col)s}') % { - 'reason': reason.text, + 'reason': reason, 'info_col': info_col } - self.add_message(kick_msg, typ=2) + self.add_message(cls(kick_msg)) - def on_user_kicked(self, presence, user, from_nick): + def on_user_kicked(self, presence: Presence, user: User, from_nick: str) -> None: """ When someone is kicked from a muc """ + cls: Type[InfoMessage] = PersistentInfoMessage self.users.remove(user) - actor_elem = presence.xml.find('{%s}x/{%s}item/{%s}actor' % - (NS_MUC_USER, NS_MUC_USER, NS_MUC_USER)) - reason = presence.xml.find('{%s}x/{%s}item/{%s}reason' % - (NS_MUC_USER, NS_MUC_USER, NS_MUC_USER)) + actor_elem = presence['muc']['item'].get_plugin('actor', check=True) + reason = presence['muc']['item']['reason'] by = None - info_col = dump_tuple(get_theme().COLOR_INFORMATION_TEXT) - char_kick = get_theme().CHAR_KICK + theme = get_theme() + info_col = dump_tuple(theme.COLOR_INFORMATION_TEXT) + char_kick = theme.CHAR_KICK if actor_elem is not None: - by = actor_elem.get('nick') or actor_elem.get('jid') + by = actor_elem['nick'] or actor_elem.get['jid'] or None if from_nick == self.own_nick: # we are kicked + cls = MucOwnLeaveMessage if by: kick_msg = ('\x191}%(spec)s \x193}You\x19' '%(info_col)s} have been kicked' @@ -826,7 +1024,7 @@ class MucTab(ChatTab): 'spec': char_kick, 'info_col': info_col } - self.core.disable_private_tabs(self.name, reason=kick_msg) + self.core.disable_private_tabs(self.jid.bare, reason=kick_msg) self.disconnect() self.refresh_tab_win() self.core.tabs.current_tab.refresh_input() @@ -836,18 +1034,18 @@ class MucTab(ChatTab): self.general_jid) delay = common.parse_str_to_secs(delay) if delay <= 0: - muc.join_groupchat(self.core, self.name, self.own_nick) + muc.join_groupchat(self.core, self.jid, self.own_nick) else: self.core.add_timed_event( timed_events.DelayedEvent(delay, muc.join_groupchat, - self.core, self.name, + self.core, self.jid, 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 + color = "3" if by: kick_msg = ('\x191}%(spec)s \x19%(color)s}%(nick)s' '\x19%(info_col)s} has been kicked by ' @@ -866,13 +1064,13 @@ class MucTab(ChatTab): 'color': color, 'info_col': info_col } - if reason is not None and reason.text: + if reason: kick_msg += ('\x19%(info_col)s} Reason: \x196}' '%(reason)s') % { - 'reason': reason.text, + 'reason': reason, 'info_col': info_col } - self.add_message(kick_msg, typ=2) + self.add_message(cls(kick_msg)) def on_user_leave_groupchat(self, user: User, @@ -880,16 +1078,16 @@ class MucTab(ChatTab): status: str, from_nick: str, from_room: JID, - server_initiated=False): + server_initiated: bool = False) -> None: """ - When an user leaves a groupchat + When a 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.core.disable_private_tabs(from_room.bare) self.refresh_tab_win() hide_exit_join = config.get_by_tabname('hide_exit_join', @@ -900,9 +1098,10 @@ class MucTab(ChatTab): 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) + color = "3" + theme = get_theme() + info_col = dump_tuple(theme.COLOR_INFORMATION_TEXT) + spec_col = dump_tuple(theme.COLOR_QUIT_CHAR) error_leave_txt = '' if server_initiated: @@ -914,18 +1113,18 @@ class MucTab(ChatTab): 'room%(error_leave)s') % { 'nick': from_nick, 'color': color, - 'spec': get_theme().CHAR_QUIT, + 'spec': theme.CHAR_QUIT, 'info_col': info_col, 'color_spec': spec_col, 'error_leave': error_leave_txt, } else: - jid_col = dump_tuple(get_theme().COLOR_MUC_JID) + jid_col = dump_tuple(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 ' 'room%(error_leave)s') % { - 'spec': get_theme().CHAR_QUIT, + 'spec': theme.CHAR_QUIT, 'nick': from_nick, 'color': color, 'jid': jid.full, @@ -936,13 +1135,13 @@ class MucTab(ChatTab): } 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, user, status) + self.add_message(PersistentInfoMessage(leave_msg)) + self.core.on_user_left_private_conversation(from_room.bare, user, status) - def on_user_change_status(self, user, from_nick, from_room, affiliation, - role, show, status): + def on_user_change_status(self, user: User, from_nick: str, from_room: str, affiliation: str, + role: str, show: str, status: str) -> None: """ - When an user changes her status + When a user changes her status """ # build the message display_message = False # flag to know if something significant enough @@ -951,17 +1150,18 @@ class MucTab(ChatTab): self.general_jid): color = dump_tuple(user.color) else: - color = 3 + color = "3" + info_col = dump_tuple(get_theme().COLOR_INFORMATION_TEXT) 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), + 'info_col': info_col, '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) + 'info_col': info_col } if affiliation != user.affiliation: msg += 'affiliation: %s, ' % affiliation @@ -994,15 +1194,16 @@ class MucTab(ChatTab): 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.add_message(InfoMessage(msg)) self.core.on_user_changed_status_in_private( - '%s/%s' % (from_room, from_nick), Status(show, status)) + JID('%s/%s' % (from_room, from_nick)), Status(show, status) + ) 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): + def disconnect(self) -> None: """ Set the state of the room as not joined, so we can know if we can join it, send messages to it, etc @@ -1014,23 +1215,13 @@ class MucTab(ChatTab): self.joined = False self.disable_self_ping_event() - def get_single_line_topic(self): + def get_single_line_topic(self) -> str: """ 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 get_user_by_name(self, nick): + def get_user_by_name(self, nick: str) -> Optional[User]: """ Gets the user associated with the given nick, or None if not found """ @@ -1039,65 +1230,34 @@ class MucTab(ChatTab): 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. - """ - + def add_message(self, msg: BaseMessage) -> None: + """Add a message to the text buffer and set various tab status""" # 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)): + super().add_message(msg) + if not isinstance(msg, Message): + return + if msg.user: + msg.user.set_last_talked(msg.time) + if config.get_by_tabname('notify_messages', self.jid) and self.state != 'current': + if msg.nickname != self.own_nick and not msg.history: 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) + if msg.txt and msg.nickname: + self.do_highlight(msg.txt, msg.nickname, msg.history) 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, corrected=True) + txt: str, + old_id: str, + new_id: str, + time: Optional[datetime] = None, + delayed: bool = False, + nickname: Optional[str] = None, + user: Optional[User] = None, + jid: Optional[JID] = None) -> bool: + highlight = self.message_is_highlight( + txt, nickname, delayed, corrected=True + ) message = self._text_buffer.modify_message( txt, old_id, @@ -1107,14 +1267,15 @@ class MucTab(ChatTab): user=user, jid=jid) if message: - self.text_win.modify_message(old_id, message) + self.log_message(message) + self.text_win.modify_message(message.identifier, message) return highlight return False - def matching_names(self): - return [(1, safeJID(self.name).user), (3, self.name)] + def matching_names(self) -> List[Tuple[int, str]]: + return [(1, self.jid.node), (3, self.jid.full)] - def enable_self_ping_event(self): + def enable_self_ping_event(self) -> None: delay = config.get_by_tabname( "self_ping_delay", self.general_jid, default=0) interval = int( @@ -1127,61 +1288,67 @@ class MucTab(ChatTab): interval, self.send_self_ping) self.core.add_timed_event(self.self_ping_event) - def disable_self_ping_event(self): + def disable_self_ping_event(self) -> None: 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): - timeout = config.get_by_tabname( - "self_ping_timeout", self.general_jid, default=60) - 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=timeout) - - def on_self_ping_result(self, iq): - if iq["type"] == "error" and iq["error"]["condition"] != "feature-not-implemented": + def send_self_ping(self) -> None: + if self.core.xmpp.is_connected(): + timeout = config.get_by_tabname( + "self_ping_timeout", self.general_jid, default=60) + to = self.jid.bare + "/" + self.own_nick + self.core.xmpp.plugin['xep_0199'].send_ping( + jid=JID(to), + callback=self.on_self_ping_result, + timeout_callback=self.on_self_ping_failed, + timeout=timeout) + else: + self.enable_self_ping_event() + + def on_self_ping_result(self, iq: Iq) -> None: + if iq["type"] == "error" and iq["error"]["condition"] not in \ + ("feature-not-implemented", "service-unavailable", "item-not-found"): 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.reset_lag() self.enable_self_ping_event() - def search_for_color(self, nick): + def search_for_color(self, nick: str) -> str: """ 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') + color = config.getstr(nick, section='muc_colors') if color != '': return color nick_color_aliases = config.get_by_tabname('nick_color_aliases', - self.name) + self.jid) if nick_color_aliases: nick_alias = re.sub('^_*(.*?)_*$', '\\1', nick) - color = config.get_by_tabname(nick_alias, 'muc_colors') + color = config.getstr(nick_alias, section='muc_colors') return color - def on_self_ping_failed(self, iq): + def on_self_ping_failed(self, iq: Any = None) -> None: if not self.lagged: self.lagged = True - info_text = dump_tuple(get_theme().COLOR_INFORMATION_TEXT) self._text_buffer.add_message( - "\x19%s}MUC service not responding." % info_text) + InfoMessage( + "MUC service not responding." + ), + ) self._state = 'disconnected' self.core.refresh_window() self.enable_self_ping_event() - def reset_lag(self): + def reset_lag(self) -> None: if self.lagged: self.lagged = False - info_text = dump_tuple(get_theme().COLOR_INFORMATION_TEXT) - self._text_buffer.add_message( - "\x19%s}MUC service is responding again." % info_text) + self.add_message( + InfoMessage("MUC service is responding again.") + ) if self != self.core.tabs.current_tab: self._state = 'joined' else: @@ -1191,35 +1358,35 @@ class MucTab(ChatTab): ########################## UI ONLY ##################################### @refresh_wrapper.always - def go_to_next_hl(self): + def go_to_next_hl(self) -> None: """ 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): + def go_to_prev_hl(self) -> None: """ Go to the previous HL in the room, or the first """ self.text_win.previous_highlight() @refresh_wrapper.always - def scroll_user_list_up(self): + def scroll_user_list_up(self) -> None: "Scroll up in the userlist" self.user_win.scroll_up() @refresh_wrapper.always - def scroll_user_list_down(self): + def scroll_user_list_down(self) -> None: "Scroll down in the userlist" self.user_win.scroll_down() - def resize(self): + def resize(self) -> None: """ 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: + if config.getbool('hide_user_list') or self.size.tab_degrade_x: text_width = self.width else: text_width = (self.width // 10) * 9 @@ -1243,18 +1410,18 @@ class MucTab(ChatTab): 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) + 0, self._text_buffer, force=self.ui_config_changed) + self.ui_config_changed = False 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): + def refresh(self) -> None: 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: + if config.getbool('hide_user_list') or self.size.tab_degrade_x: display_user_list = False else: display_user_list = True @@ -1273,10 +1440,10 @@ class MucTab(ChatTab): self.info_win.refresh() self.input.refresh() - def on_info_win_size_changed(self): + def on_info_win_size_changed(self) -> None: if self.core.information_win_size >= self.height - 3: return - if config.get("hide_user_list"): + if config.getbool("hide_user_list"): text_width = self.width else: text_width = (self.width // 10) * 9 @@ -1289,7 +1456,7 @@ class MucTab(ChatTab): 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) + Tab.tab_win_height(), text_width, 1, 0, self._text_buffer) self.info_header.resize( 1, self.width, self.height - 2 - self.core.information_win_size - Tab.tab_win_height(), 0) @@ -1297,37 +1464,42 @@ class MucTab(ChatTab): # This maxsize is kinda arbitrary, but most users won’t have that many # nicknames anyway. @functools.lru_cache(maxsize=8) - def build_highlight_regex(self, nickname): + def build_highlight_regex(self, nickname: str) -> Pattern: return re.compile(r"(^|\W)" + re.escape(nickname) + r"(\W|$)", re.I) - def is_highlight(self, txt, time, nickname, own_nick, highlight_on, - corrected=False): + def message_is_highlight(self, txt: str, nickname: Optional[str], history: bool, + corrected: bool = False) -> bool: + """Highlight algorithm for MUC tabs""" + # Don't highlight on info message or our own messages + if not nickname or nickname == self.own_nick: + return False + highlight_on = config.get_by_tabname( + 'highlight_on', + self.general_jid, + ).split(':') highlighted = False - if (not time or corrected) and nickname and nickname != own_nick: - if self.build_highlight_regex(own_nick).search(txt): + if not history: + if self.build_highlight_regex(self.own_nick).search(txt): highlighted = True else: - highlight_words = highlight_on.split(':') - for word in highlight_words: + for word in highlight_on: if word and word.lower() in txt.lower(): highlighted = True break return highlighted - def do_highlight(self, txt, time, nickname, corrected=False): - """ - Set the tab color and returns the nick color - """ - own_nick = self.own_nick - highlight_on = config.get_by_tabname('highlight_on', self.general_jid) - highlighted = self.is_highlight(txt, time, nickname, own_nick, - highlight_on, corrected) - if highlighted and self.joined: + def do_highlight(self, txt: str, nickname: str, history: bool, + corrected: bool = False) -> bool: + """Set the tab color and returns the highlight state""" + highlighted = self.message_is_highlight( + txt, nickname, history, corrected + ) + if highlighted and self.joined and not corrected: if self.state != 'current': self.state = 'highlight' - beep_on = config.get('beep_on').split() + beep_on = config.getstr('beep_on').split() if 'highlight' in beep_on and 'message' not in beep_on: - if not config.get_by_tabname('disable_beep', self.name): + if not config.get_by_tabname('disable_beep', self.jid): curses.beep() return True return False @@ -1335,56 +1507,57 @@ class MucTab(ChatTab): ########################## COMMANDS #################################### @command_args_parser.quoted(1, 1, ['']) - def command_invite(self, args): + async def command_invite(self, args: List[str]) -> None: """/invite <jid> [reason]""" if args is None: - return self.core.command.help('invite') + self.core.command.help('invite') + return jid, reason = args - self.core.command.invite('%s %s "%s"' % (jid, self.name, reason)) + await self.core.command.invite('%s %s "%s"' % (jid, self.jid, reason)) @command_args_parser.quoted(1) - def command_info(self, args): + def command_info(self, args: List[str]) -> None: """ /info <nick> """ if args is None: - return self.core.command.help('info') + self.core.command.help('info') + return nick = args[0] if not self.print_info(nick): self.core.information("Unknown user: %s" % nick, "Error") @command_args_parser.quoted(0) - def command_configure(self, ignored): + async def command_configure(self, ignored: Any) -> None: """ /configure """ - def on_form_received(form): - if not form: - self.core.information( - 'Could not retrieve the configuration form', 'Error') - return + try: + form = await self.core.xmpp.plugin['xep_0045'].get_room_config( + self.jid + ) self.core.open_new_form(form, self.cancel_config, self.send_config) - - fixes.get_room_form(self.core.xmpp, self.name, on_form_received) + except (IqError, IqTimeout, ValueError): + self.core.information( + 'Could not retrieve the configuration form', 'Error') @command_args_parser.raw - def command_cycle(self, msg): + def command_cycle(self, msg: str) -> None: """/cycle [reason]""" self.leave_room(msg) self.join() - @command_args_parser.quoted(0, 1, ['']) - def command_recolor(self, args): + @command_args_parser.ignored + def command_recolor(self) -> None: """ /recolor [random] Re-assigns color to the participants of the room """ - random_colors = args[0] == 'random' - self.recolor(random_colors) + self.recolor() @command_args_parser.quoted(2, 2, ['']) - def command_color(self, args): + def command_color(self, args: List[str]) -> None: """ /color <nick> <color> Fix a color for a nick. @@ -1392,52 +1565,71 @@ class MucTab(ChatTab): User "random" to attribute a random color. """ if args is None: - return self.core.command.help('color') + self.core.command.help('color') + return nick = args[0] color = args[1].lower() if nick == self.own_nick: - return self.core.information( + self.core.information( "You cannot change the color of your" - " own nick.", 'Error') + " own nick.", 'Error' + ) elif color not in xhtml.colors and color not in ('unset', 'random'): - return self.core.information("Unknown color: %s" % color, 'Error') - self.set_nick_color(nick, color) + self.core.information("Unknown color: %s" % color, 'Error') + else: + self.set_nick_color(nick, color) @command_args_parser.quoted(1) - def command_version(self, args): + async def command_version(self, args: List[str]) -> None: """ /version <jid or nick> """ if args is None: - return self.core.command.help('version') + self.core.command.help('version') + return 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) - self.core.xmpp.plugin['xep_0092'].get_version( - jid, callback=self.core.handler.on_version_result) + try: + if nick in {user.nick for user in self.users}: + jid = copy(self.jid) + jid.resource = nick + else: + jid = JID(nick) + except InvalidJID: + self.core.information('Invalid jid or nick %r' % nick, 'Error') + return + iq = await self.core.xmpp.plugin['xep_0092'].get_version(jid) + self.core.handler.on_version_result(iq) @command_args_parser.quoted(1) - def command_nick(self, args): + def command_nick(self, args: List[str]) -> None: """ /nick <nickname> """ if args is None: - return self.core.command.help('nick') + self.core.command.help('nick') + return nick = args[0] if not self.joined: - return self.core.information('/nick only works in joined rooms', + self.core.information('/nick only works in joined rooms', 'Info') + return 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) + try: + target_jid = copy(self.jid) + target_jid.resource = nick + except InvalidJID: + self.core.information('Invalid nick', 'Info') + return + muc.change_nick( + self.core, + self.jid, + nick, + current_status.message, + current_status.show, + ) @command_args_parser.quoted(0, 1, ['']) - def command_part(self, args): + def command_part(self, args: List[str]) -> None: """ /part [msg] """ @@ -1448,38 +1640,58 @@ class MucTab(ChatTab): self.core.doupdate() @command_args_parser.raw - def command_close(self, msg): + def command_leave(self, msg: str) -> None: + """ + /leave [msg] + """ + self.command_close(msg) + + @command_args_parser.raw + def command_close(self, msg: str) -> None: """ /close [msg] """ self.leave_room(msg) + if config.getbool('synchronise_open_rooms'): + if self.jid in self.core.bookmarks: + bookmark = self.core.bookmarks[self.jid] + if bookmark: + bookmark.autojoin = False + asyncio.create_task( + self.core.bookmarks.save(self.core.xmpp) + ) self.core.close_tab(self) - def on_close(self): + def on_close(self) -> None: super().on_close() - self.leave_room('') + if self.joined: + self.leave_room('') @command_args_parser.quoted(1, 1) - def command_query(self, args): + def command_query(self, args: List[str]) -> None: """ /query <nick> [message] """ if args is None: - return self.core.command.help('query') + self.core.command.help('query') + return nick = args[0] r = None for user in self.users: if user.nick == nick: - r = self.core.open_private_window(self.name, user.nick) + r = self.core.open_private_window(self.jid.bare, user.nick) if r and len(args) == 2: msg = args[1] - self.core.tabs.current_tab.command_say( - xhtml.convert_simple_to_full_colors(msg)) + asyncio.ensure_future( + r.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): + def command_topic(self, subject: str) -> None: """ /topic [new topic] """ @@ -1489,30 +1701,31 @@ class MucTab(ChatTab): self.change_topic(subject) @command_args_parser.quoted(0) - def command_names(self, args): + def command_names(self, args: Any) -> None: """ /names """ if not self.joined: return + theme = get_theme() 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, + 'owner': theme.CHAR_AFFILIATION_OWNER, + 'admin': theme.CHAR_AFFILIATION_ADMIN, + 'member': theme.CHAR_AFFILIATION_MEMBER, + 'none': 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) + colors["visitor"] = dump_tuple(theme.COLOR_USER_VISITOR) + colors["moderator"] = dump_tuple(theme.COLOR_USER_MODERATOR) + colors["participant"] = dump_tuple(theme.COLOR_USER_PARTICIPANT) + color_other = dump_tuple(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) + theme.CHAR_AFFILIATION_NONE) color = colors.get(user.role, color_other) buff.append( '\x19%s}%s\x19o\x19%s}%s\x19o' % @@ -1521,79 +1734,137 @@ class MucTab(ChatTab): buff.append('\n') message = ' '.join(buff) - self._text_buffer.add_message(message) + self.add_message(InfoMessage(message)) self.text_win.refresh() self.input.refresh() @command_args_parser.quoted(1, 1) - def command_kick(self, args): + async def command_kick(self, args: List[str]) -> None: """ /kick <nick> [reason] """ if args is None: - return self.core.command.help('kick') + self.core.command.help('kick') + return if len(args) == 2: reason = args[1] else: reason = '' nick = args[0] - self.change_role(nick, 'none', reason) + await self.change_role(nick, 'none', reason) @command_args_parser.quoted(1, 1) - def command_ban(self, args): + async def command_ban(self, args: List[str]) -> None: """ /ban <nick> [reason] """ if args is None: - return self.core.command.help('ban') + self.core.command.help('ban') + return nick = args[0] msg = args[1] if len(args) == 2 else '' - self.change_affiliation(nick, 'outcast', msg) + await self.change_affiliation(nick, 'outcast', msg) @command_args_parser.quoted(2, 1, ['']) - def command_role(self, args): + async def command_role(self, args: List[str]) -> None: """ /role <nick> <role> [reason] - Changes the role of an user + Changes the role of a 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') + self.core.command.help('role') + return nick, role, reason = args[0], args[1].lower(), args[2] - self.change_role(nick, role, reason) + try: + await self.change_role(nick, role, reason) + except IqError as iq: + self.core.room_error(iq, self.jid.bare) - @command_args_parser.quoted(2) - def command_affiliation(self, args): + @command_args_parser.quoted(0, 2) + async def command_affiliation(self, args: List[str]) -> None: """ - /affiliation <nick> <role> - Changes the affiliation of an user + /affiliation [<nick or jid> <affiliation>] + Changes the affiliation of a user affiliations can be: outcast, none, member, admin, owner """ - def callback(iq): - if iq['type'] == 'error': - self.core.room_error(iq, self.name) + room = JID(self.name) + if not room: + self.core.information('affiliation: requires a valid chat address', 'Error') + return - if args is None: - return self.core.command.help('affiliation') + # List affiliations + if not args: + await self.get_users_affiliations(room) + return None + + if len(args) != 2: + self.core.command.help('affiliation') + return nick, affiliation = args[0], args[1].lower() - self.change_affiliation(nick, affiliation) + # Set affiliation + await self.change_affiliation(nick, affiliation) + + async def get_users_affiliations(self, jid: JID) -> None: + owners, admins, members, outcasts = await asyncio.gather( + self.core.xmpp['xep_0045'].get_affiliation_list(jid, 'owner'), + self.core.xmpp['xep_0045'].get_affiliation_list(jid, 'admin'), + self.core.xmpp['xep_0045'].get_affiliation_list(jid, 'member'), + self.core.xmpp['xep_0045'].get_affiliation_list(jid, 'outcast'), + return_exceptions=True, + ) + + all_errors = functools.reduce( + lambda acc, iq: acc and isinstance(iq, (IqError, IqTimeout)), + (owners, admins, members, outcasts), + True, + ) + if all_errors: + self.core.information( + 'Can’t access affiliations for %s' % jid.bare, + 'Error', + ) + return None + + theme = get_theme() + aff_colors = { + 'owner': theme.CHAR_AFFILIATION_OWNER, + 'admin': theme.CHAR_AFFILIATION_ADMIN, + 'member': theme.CHAR_AFFILIATION_MEMBER, + 'outcast': theme.CHAR_AFFILIATION_OUTCAST, + } + + + + lines = ['Affiliations for %s' % jid.bare] + affiliation_dict = { + 'owner': owners, + 'admin': admins, + 'member': members, + 'outcast': outcasts, + } + for affiliation, items in affiliation_dict.items(): + if isinstance(items, BaseException) or not items: + continue + aff_char = aff_colors[affiliation] + lines.append(' %s%s' % (aff_char, affiliation.capitalize())) + for ajid in sorted(items): + lines.append(' %s' % ajid) + + self.core.information('\n'.join(lines), 'Info') + return None @command_args_parser.raw - def command_say(self, line, correct=False): + async def command_say(self, line: str, attention: bool = False, correct: bool = False): """ /say <message> Or normal input + enter """ - needed = 'inactive' if self.inactive else 'active' - msg = self.core.xmpp.make_message(self.name) + chatstate = 'inactive' if self.inactive else 'active' + msg: SMessage = self.core.xmpp.make_message(self.jid) msg['type'] = 'groupchat' msg['body'] = line # trigger the event BEFORE looking for colors. @@ -1610,9 +1881,12 @@ class MucTab(ChatTab): 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): - msg['chat_state'] = needed + if chatstate == 'inactive': + self.send_chat_state(chatstate, always_send=True) + else: + msg['chat_state'] = chatstate if correct: - msg['replace']['id'] = self.last_sent_message['id'] + msg['replace']['id'] = self.last_sent_message['id'] # type: ignore self.cancel_paused_delay() self.core.events.trigger('muc_say_after', msg, self) if not msg['body']: @@ -1620,24 +1894,26 @@ class MucTab(ChatTab): self.text_win.refresh() self.input.refresh() return - self.last_sent_message = msg + # TODO: #3314. Display outgoing MUC message. + self.set_last_sent_message(msg, correct=correct) msg.send() - self.chat_state = needed + self.chat_state = chatstate @command_args_parser.raw - def command_xhtml(self, msg): + def command_xhtml(self, msg: str) -> None: message = self.generate_xhtml_message(msg) if message: message['type'] = 'groupchat' message.send() @command_args_parser.quoted(1) - def command_ignore(self, args): + def command_ignore(self, args: List[str]) -> None: """ /ignore <nick> """ if args is None: - return self.core.command.help('ignore') + self.core.command.help('ignore') + return nick = args[0] user = self.get_user_by_name(nick) @@ -1650,12 +1926,13 @@ class MucTab(ChatTab): self.core.information("%s is now ignored" % nick, 'info') @command_args_parser.quoted(1) - def command_unignore(self, args): + def command_unignore(self, args: List[str]) -> None: """ /unignore <nick> """ if args is None: - return self.core.command.help('unignore') + self.core.command.help('unignore') + return nick = args[0] user = self.get_user_by_name(nick) @@ -1667,9 +1944,33 @@ class MucTab(ChatTab): self.ignores.remove(user) self.core.information('%s is now unignored' % nick) + @command_args_parser.quoted(0, 1) + def command_request_voice(self, args: List[str]) -> None: + """ + /request_voice [role] + Request voice in a moderated room + role can be: participant, moderator + """ + + room = JID(self.name) + if not room: + self.core.information('request_voice: requires a valid chat address', 'Error') + return + + if len(args) > 1: + self.core.command.help('request_voice') + return + + if args: + role = args[0] + else: + role = 'participant' + + self.core.xmpp['xep_0045'].request_voice(room, role) + ########################## COMPLETIONS ################################# - def completion(self): + def completion(self) -> None: """ Called when Tab is pressed, complete the nickname in the input """ @@ -1682,14 +1983,15 @@ class MucTab(ChatTab): for user in sorted(self.users, key=COMPARE_USERS_LAST_TALKED, reverse=True): if user.nick != self.own_nick: word_list.append(user.nick) - after = config.get('after_completion') + ' ' + after = config.getstr('after_completion') + ' ' input_pos = self.input.pos - if ' ' not in self.input.get_text()[:input_pos] or ( + text_before = self.input.get_text()[:input_pos] + if (' ' not in text_before and '\n' not in text_before) 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'): + if not config.getbool('add_space_after_completion'): add_after = '' else: add_after = ' ' @@ -1700,7 +2002,7 @@ class MucTab(ChatTab): and not self.input.get_text().startswith('//')) self.send_composing_chat_state(empty_after) - def completion_version(self, the_input): + def completion_version(self, the_input: windows.MessageInput) -> Completion: """Completion for /version""" userlist = [] for user in sorted(self.users, key=COMPARE_USERS_LAST_TALKED, reverse=True): @@ -1715,30 +2017,30 @@ class MucTab(ChatTab): return Completion(the_input.auto_completion, userlist, quotify=False) - def completion_info(self, the_input): + def completion_info(self, the_input: windows.MessageInput) -> Completion: """Completion for /info""" userlist = [] for user in sorted(self.users, key=COMPARE_USERS_LAST_TALKED, reverse=True): userlist.append(user.nick) return Completion(the_input.auto_completion, userlist, quotify=False) - def completion_nick(self, the_input): + def completion_nick(self, the_input: windows.MessageInput) -> Completion: """Completion for /nick""" - nicks = [ + nicks_list = [ os.environ.get('USER'), - config.get('default_nick'), - self.core.get_bookmark_nickname(self.name) + config.getstr('default_nick'), + self.core.get_bookmark_nickname(self.jid.bare) ] - nicks = [i for i in nicks if i] + nicks = [i for i in nicks_list if i] return Completion(the_input.auto_completion, nicks, '', quotify=False) - def completion_recolor(self, the_input): + def completion_recolor(self, the_input: windows.MessageInput) -> Optional[Completion]: if the_input.get_argument_position() == 1: return Completion( the_input.new_completion, ['random'], 1, '', quotify=False) - return True + return None - def completion_color(self, the_input): + def completion_color(self, the_input: windows.MessageInput) -> Optional[Completion]: """Completion for /color""" n = the_input.get_argument_position(quoted=True) if n == 1: @@ -1754,8 +2056,9 @@ class MucTab(ChatTab): colors.append('random') return Completion( the_input.new_completion, colors, 2, '', quotify=False) + return None - def completion_ignore(self, the_input): + def completion_ignore(self, the_input: windows.MessageInput) -> Completion: """Completion for /ignore""" userlist = [user.nick for user in self.users] if self.own_nick in userlist: @@ -1763,7 +2066,7 @@ class MucTab(ChatTab): userlist.sort() return Completion(the_input.auto_completion, userlist, quotify=False) - def completion_role(self, the_input): + def completion_role(self, the_input: windows.MessageInput) -> Optional[Completion]: """Completion for /role""" n = the_input.get_argument_position(quoted=True) if n == 1: @@ -1776,8 +2079,9 @@ class MucTab(ChatTab): possible_roles = ['none', 'visitor', 'participant', 'moderator'] return Completion( the_input.new_completion, possible_roles, 2, '', quotify=True) + return None - def completion_affiliation(self, the_input): + def completion_affiliation(self, the_input: windows.MessageInput) -> Optional[Completion]: """Completion for /affiliation""" n = the_input.get_argument_position(quoted=True) if n == 1: @@ -1800,20 +2104,26 @@ class MucTab(ChatTab): 2, '', quotify=True) + return None - def completion_invite(self, the_input): + def completion_invite(self, the_input: windows.MessageInput) -> Optional[Completion]: """Completion for /invite""" n = the_input.get_argument_position(quoted=True) if n == 1: return Completion( - the_input.new_completion, roster.jids(), 1, quotify=True) + the_input.new_completion, + [str(i) for i in roster.jids()], + argument_position=1, + quotify=True) + return None - def completion_topic(self, the_input): + def completion_topic(self, the_input: windows.MessageInput) -> Optional[Completion]: if the_input.get_argument_position() == 1: return Completion( the_input.auto_completion, [self.topic], '', quotify=False) + return None - def completion_quoted(self, the_input): + def completion_quoted(self, the_input: windows.MessageInput) -> Optional[Completion]: """Nick completion, but with quotes""" if the_input.get_argument_position(quoted=True) == 1: word_list = [] @@ -1823,16 +2133,23 @@ class MucTab(ChatTab): return Completion( the_input.new_completion, word_list, 1, quotify=True) + return None - def completion_unignore(self, the_input): + def completion_unignore(self, the_input: windows.MessageInput) -> Optional[Completion]: if the_input.get_argument_position() == 1: users = [user.nick for user in self.ignores] return Completion(the_input.auto_completion, users, quotify=False) + return None + + def completion_request_voice(self, the_input: windows.MessageInput) -> Optional[Completion]: + """Completion for /request_voice""" + allowed = ['participant', 'moderator'] + return Completion(the_input.auto_completion, allowed, quotify=False) ########################## REGISTER STUFF ############################## - def register_keys(self): + def register_keys(self) -> None: "Register tab-specific keys" self.key_func['^I'] = self.completion self.key_func['M-u'] = self.scroll_user_list_down @@ -1840,7 +2157,7 @@ class MucTab(ChatTab): self.key_func['M-n'] = self.go_to_next_hl self.key_func['M-p'] = self.go_to_prev_hl - def register_commands(self): + def register_commands(self) -> None: "Register tab-specific commands" self.register_commands_batch([{ 'name': 'ignore', @@ -1895,11 +2212,11 @@ class MucTab(ChatTab): self.command_role, 'usage': '<nick> <role> [reason]', - 'desc': ('Set the role of an user. Roles can be:' + 'desc': ('Set the role of a user. Roles can be:' ' none, visitor, participant, moderator.' ' You also can give an optional reason.'), 'shortdesc': - 'Set the role of an user.', + 'Set the role of a user.', 'completion': self.completion_role }, { @@ -1908,11 +2225,11 @@ class MucTab(ChatTab): 'func': self.command_affiliation, 'usage': - '<nick or jid> <affiliation>', - 'desc': ('Set the affiliation of an user. Affiliations can be:' + '[<nick or jid> [<affiliation>]]', + 'desc': ('Set the affiliation of a user. Affiliations can be:' ' outcast, none, member, admin, owner.'), 'shortdesc': - 'Set the affiliation of an user.', + 'Set the affiliation of a user.', 'completion': self.completion_affiliation }, { @@ -1968,15 +2285,23 @@ class MucTab(ChatTab): 'shortdesc': 'Leave the room.' }, { + 'name': 'leave', + 'func': self.command_leave, + 'usage': '[message]', + 'desc': 'Deprecated alias for /close', + 'shortdesc': 'Leave the room.' + }, { 'name': 'close', 'func': 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.'), + 'desc': ('Disconnect from a room and close the tab. ' + 'You can specify an optional message if ' + 'you are still connected. If synchronise_open_tabs ' + 'is true, also disconnect you from your other ' + 'clients.'), 'shortdesc': 'Close the tab.' }, { @@ -1998,12 +2323,11 @@ class MucTab(ChatTab): 'func': 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.'), + '', + 'desc': ( + 'Re-assign a color to all participants of the room ' + 'if the theme has changed.' + ), 'shortdesc': 'Change the nicks colors.', 'completion': @@ -2020,7 +2344,7 @@ class MucTab(ChatTab): 'shortdesc': 'Fix a color for a nick.', 'completion': - self.completion_recolor + self.completion_color }, { 'name': 'cycle', @@ -2040,10 +2364,10 @@ class MucTab(ChatTab): 'usage': '<nickname>', 'desc': ('Display some information about the user ' - 'in the MUC: its/his/her role, affiliation,' + 'in the MUC: their role, affiliation,' ' status and status message.'), 'shortdesc': - 'Show an user\'s infos.', + 'Show a user\'s infos.', 'completion': self.completion_info }, { @@ -2091,6 +2415,19 @@ class MucTab(ChatTab): 'Invite a contact to this room', 'completion': self.completion_invite + }, { + 'name': + 'request_voice', + 'func': + self.command_request_voice, + 'desc': + 'Request voice when we are a visitor in a moderated room', + 'usage': + '[role]', + 'shortdesc': + 'Request voice in a moderated room', + 'completion': + self.completion_request_voice }]) @@ -2098,7 +2435,7 @@ class PresenceError(Exception): pass -def dissect_presence(presence): +def dissect_presence(presence: Presence) -> Tuple[str, str, str, str, str, str, JID, str]: """ Extract relevant information from a presence """ diff --git a/poezio/tabs/privatetab.py b/poezio/tabs/privatetab.py index 8f5f4d6f..1909e3c1 100644 --- a/poezio/tabs/privatetab.py +++ b/poezio/tabs/privatetab.py @@ -10,40 +10,46 @@ both participant’s nicks. It also has slightly different features than the ConversationTab (such as tab-completion on nicks from the room). """ +import asyncio import curses import logging +from datetime import datetime from typing import Dict, Callable +from slixmpp import JID +from slixmpp.stanza import Message as SMessage + from poezio.tabs import OneToOneTab, MucTab, Tab +from poezio import common from poezio import windows from poezio import xhtml -from poezio.common import safeJID -from poezio.config import config +from poezio.config import config, get_image_cache from poezio.core.structs import Command from poezio.decorators import refresh_wrapper -from poezio.logger import logger from poezio.theming import get_theme, dump_tuple from poezio.decorators import command_args_parser +from poezio.text_buffer import CorrectionError +from poezio.ui.types import ( + Message, + PersistentInfoMessage, +) log = logging.getLogger(__name__) class PrivateTab(OneToOneTab): """ - The tab containg a private conversation (someone from a MUC) + The tab containing a private conversation (someone from a MUC) """ - plugin_commands = {} # type: Dict[str, Command] - plugin_keys = {} # type: Dict[str, Callable] + plugin_commands: Dict[str, Command] = {} + plugin_keys: Dict[str, Callable] = {} message_type = 'chat' - additional_information = {} # type: Dict[str, Callable[[str], str]] + additional_information: Dict[str, Callable[[str], str]] = {} - def __init__(self, core, name, nick): - OneToOneTab.__init__(self, core, name) + def __init__(self, core, jid, nick, initial=None): + OneToOneTab.__init__(self, core, jid, initial) 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 @@ -53,7 +59,7 @@ class PrivateTab(OneToOneTab): 'info', self.command_info, desc= - 'Display some information about the user in the MUC: its/his/her role, affiliation, status and status message.', + 'Display some information about the user in the MUC: their role, affiliation, status and status message.', shortdesc='Info about the user.') self.register_command( 'version', @@ -62,30 +68,41 @@ class PrivateTab(OneToOneTab): '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.tabs.by_name_and_class( - safeJID(name).bare, MucTab) + self.parent_muc = self.core.tabs.by_name_and_class(self.jid.bare, MucTab) self.on = True self.update_commands() self.update_keys() + @property + def log_name(self) -> str: + """Overriden from ChatTab because this is a case where we want the full JID""" + return self.jid.full + def remote_user_color(self): - user = self.parent_muc.get_user_by_name(safeJID(self.name).resource) + user = self.parent_muc.get_user_by_name(self.jid.resource) if user: return dump_tuple(user.color) return super().remote_user_color() @property - def general_jid(self): - return self.name + def general_jid(self) -> JID: + return self.jid - def get_dest_jid(self): - return self.name + def get_dest_jid(self) -> JID: + return self.jid @property - def nick(self): + def nick(self) -> str: return self.get_nick() + def ack_message(self, msg_id: str, msg_jid: JID): + if JID(msg_jid).bare == self.core.xmpp.boundjid.bare: + msg_jid = JID(self.jid.bare) + msg_jid.resource = self.own_nick + super().ack_message(msg_id, msg_jid) + @staticmethod + @refresh_wrapper.always def add_information_element(plugin_name, callback): """ Lets a plugin add its own information to the PrivateInfoWin @@ -93,22 +110,10 @@ class PrivateTab(OneToOneTab): PrivateTab.additional_information[plugin_name] = callback @staticmethod + @refresh_wrapper.always def remove_information_element(plugin_name): del PrivateTab.additional_information[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): super().on_close() self.parent_muc.privates.remove(self) @@ -124,7 +129,7 @@ class PrivateTab(OneToOneTab): 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') + ' ' + after = config.getstr('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): @@ -137,38 +142,87 @@ class PrivateTab(OneToOneTab): and not self.input.get_text().startswith('//')) self.send_composing_chat_state(empty_after) + async def handle_message(self, message: SMessage, display: bool = True): + sent = message['from'].bare == self.core.xmpp.boundjid.bare + jid = message['to'] if sent else message['from'] + with_nick = jid.resource + sender_nick = with_nick + if sent: + sender_nick = (self.own_nick or self.core.own_nick) + room_from = jid.bare + use_xhtml = config.get_by_tabname( + 'enable_xhtml_im', + jid.bare + ) + tmp_dir = get_image_cache() + if not sent: + await self.core.events.trigger_async('private_msg', message, self) + body = xhtml.get_body_from_message_stanza( + message, use_xhtml=use_xhtml, extract_images_to=tmp_dir) + if not body or not self: + return + delayed, date = common.find_delayed_tag(message) + replaced = False + user = self.parent_muc.get_user_by_name(with_nick) + if message.get_plugin('replace', check=True): + replaced_id = message['replace']['id'] + if replaced_id != '' and config.get_by_tabname( + 'group_corrections', room_from): + try: + self.modify_message( + body, + replaced_id, + message['id'], + user=user, + time=date, + jid=message['from'], + nickname=sender_nick) + replaced = True + except CorrectionError: + log.debug('Unable to correct a message', exc_info=True) + if not replaced: + msg = Message( + txt=body, + time=date, + history=delayed, + nickname=sender_nick, + nick_color=get_theme().COLOR_OWN_NICK if sent else None, + user=user, + identifier=message['id'], + jid=message['from'], + ) + if display: + self.add_message(msg) + else: + self.log_message(msg) + if sent: + self.set_last_sent_message(message, correct=replaced) + else: + self.last_remote_message = datetime.now() + + @refresh_wrapper.always @command_args_parser.raw - def command_say(self, line, attention=False, correct=False): + async def command_say(self, line: str, attention: bool = False, correct: bool = False) -> None: if not self.on: return - msg = self.core.xmpp.make_message(self.name) + await self._initial_log.wait() + our_jid = JID(self.jid.bare) + our_jid.resource = self.own_nick + msg: SMessage = self.core.xmpp.make_message( + mto=self.jid.full, + mfrom=our_jid, + ) msg['type'] = 'chat' msg['body'] = line + msg.enable('muc') # 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) + if correct or msg['replace']['id'] and self.last_sent_message: + msg['replace']['id'] = self.last_sent_message['id'] # type: ignore else: del msg['replace'] @@ -177,43 +231,32 @@ class PrivateTab(OneToOneTab): 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): - needed = 'inactive' if self.inactive else 'active' - msg['chat_state'] = needed + if self.inactive: + self.send_chat_state('inactive', always_send=True) + else: + msg['chat_state'] = 'active' if 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 - msg._add_receipt = True + self.set_last_sent_message(msg, correct=correct) + await self.core.handler.on_groupchat_private_message(msg, sent=True) + # Our receipts slixmpp hack + msg._add_receipt = True # type: ignore msg.send() self.cancel_paused_delay() - self.text_win.refresh() - self.input.refresh() @command_args_parser.quoted(0, 1) - def command_version(self, args): + async def command_version(self, args): """ /version """ if args: - return self.core.command.version(args[0]) - jid = safeJID(self.name) - self.core.xmpp.plugin['xep_0092'].get_version( - jid, callback=self.core.handler.on_version_result) + return await self.core.command.version(args[0]) + jid = self.jid.full + iq = await self.core.xmpp.plugin['xep_0092'].get_version(jid) + self.core.handler.on_version_result(iq) @command_args_parser.quoted(0, 1) def command_info(self, arg): @@ -223,7 +266,7 @@ class PrivateTab(OneToOneTab): if arg and arg[0]: self.parent_muc.command_info(arg[0]) else: - user = safeJID(self.name).resource + user = self.jid.resource self.parent_muc.command_info(user) def resize(self): @@ -238,8 +281,8 @@ class PrivateTab(OneToOneTab): 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) + 0, self._text_buffer, force=self.ui_config_changed) + self.ui_config_changed = False self.info_header.resize( 1, self.width, self.height - 2 - info_win_height - tab_win_height, 0) @@ -252,7 +295,7 @@ class PrivateTab(OneToOneTab): display_info_win = not self.size.tab_degrade_y self.text_win.refresh() - self.info_header.refresh(self.name, self.text_win, self.chatstate, + self.info_header.refresh(self.jid.full, self.text_win, self.chatstate, PrivateTab.additional_information) if display_info_win: self.info_win.refresh() @@ -261,12 +304,12 @@ class PrivateTab(OneToOneTab): self.input.refresh() def refresh_info_header(self): - self.info_header.refresh(self.name, self.text_win, self.chatstate, + self.info_header.refresh(self.jid.full, self.text_win, self.chatstate, PrivateTab.additional_information) self.input.refresh() def get_nick(self): - return safeJID(self.name).resource + return self.jid.resource def on_input(self, key, raw): if not raw and key in self.key_func: @@ -278,7 +321,7 @@ class PrivateTab(OneToOneTab): empty_after = self.input.get_text() == '' or ( self.input.get_text().startswith('/') and not self.input.get_text().startswith('//')) - tab = self.core.tabs.by_name_and_class(safeJID(self.name).bare, MucTab) + tab = self.core.tabs.by_name_and_class(self.jid.bare, MucTab) if tab and tab.joined: self.send_composing_chat_state(empty_after) return False @@ -291,7 +334,7 @@ class PrivateTab(OneToOneTab): self.text_win.remove_line_separator() self.text_win.add_line_separator(self._text_buffer) - tab = self.core.tabs.by_name_and_class(safeJID(self.name).bare, MucTab) + tab = self.core.tabs.by_name_and_class(self.jid.bare, MucTab) if tab and tab.joined and config.get_by_tabname( 'send_chat_states', self.general_jid) and self.on: self.send_chat_state('inactive') @@ -300,7 +343,7 @@ class PrivateTab(OneToOneTab): def on_gain_focus(self): self.state = 'current' curses.curs_set(1) - tab = self.core.tabs.by_name_and_class(safeJID(self.name).bare, MucTab) + tab = self.core.tabs.by_name_and_class(self.jid.bare, MucTab) if tab and tab.joined and config.get_by_tabname( 'send_chat_states', self.general_jid, @@ -317,9 +360,6 @@ class PrivateTab(OneToOneTab): 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, user): """ @@ -327,16 +367,18 @@ class PrivateTab(OneToOneTab): display a message. """ self.add_message( - '\x19%(nick_col)s}%(old)s\x19%(info_col)s} is now ' - 'known as \x19%(nick_col)s}%(new)s' % { - 'old': old_nick, - 'new': user.nick, - 'nick_col': dump_tuple(user.color), - 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT) - }, - typ=2) - new_jid = safeJID(self.name).bare + '/' + user.nick - self.name = new_jid + PersistentInfoMessage( + '\x19%(nick_col)s}%(old)s\x19%(info_col)s} is now ' + 'known as \x19%(nick_col)s}%(new)s' % { + 'old': old_nick, + 'new': user.nick, + 'nick_col': dump_tuple(user.color), + 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT) + }, + ), + ) + new_jid = self.jid.bare + '/' + user.nick + self._name = new_jid return self.core.tabs.current_tab is self @refresh_wrapper.conditional @@ -345,36 +387,41 @@ class PrivateTab(OneToOneTab): The user left the associated MUC """ self.deactivate() + theme = get_theme() if config.get_by_tabname('display_user_color_in_join_part', self.general_jid): color = dump_tuple(user.color) else: - color = dump_tuple(get_theme().COLOR_REMOTE_USER) + color = dump_tuple(theme.COLOR_REMOTE_USER) if not status_message: self.add_message( - '\x19%(quit_col)s}%(spec)s \x19%(nick_col)s}' - '%(nick)s\x19%(info_col)s} has left the room' % { - 'nick': user.nick, - 'spec': get_theme().CHAR_QUIT, - 'nick_col': color, - 'quit_col': dump_tuple(get_theme().COLOR_QUIT_CHAR), - 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT) - }, - typ=2) + PersistentInfoMessage( + '\x19%(quit_col)s}%(spec)s \x19%(nick_col)s}' + '%(nick)s\x19%(info_col)s} has left the room' % { + 'nick': user.nick, + 'spec': theme.CHAR_QUIT, + 'nick_col': color, + 'quit_col': dump_tuple(theme.COLOR_QUIT_CHAR), + 'info_col': dump_tuple(theme.COLOR_INFORMATION_TEXT) + }, + ), + ) else: self.add_message( - '\x19%(quit_col)s}%(spec)s \x19%(nick_col)s}' - '%(nick)s\x19%(info_col)s} has left the room' - ' (%(status)s)' % { - 'status': status_message, - 'nick': user.nick, - 'spec': get_theme().CHAR_QUIT, - 'nick_col': color, - 'quit_col': dump_tuple(get_theme().COLOR_QUIT_CHAR), - 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT) - }, - typ=2) + PersistentInfoMessage( + '\x19%(quit_col)s}%(spec)s \x19%(nick_col)s}' + '%(nick)s\x19%(info_col)s} has left the room' + ' (%(status)s)' % { + 'status': status_message, + 'nick': user.nick, + 'spec': theme.CHAR_QUIT, + 'nick_col': color, + 'quit_col': dump_tuple(theme.COLOR_QUIT_CHAR), + 'info_col': dump_tuple(theme.COLOR_INFORMATION_TEXT) + }, + ), + ) return self.core.tabs.current_tab is self @refresh_wrapper.conditional @@ -383,46 +430,51 @@ class PrivateTab(OneToOneTab): The user (or at least someone with the same nick) came back in the MUC """ self.activate() - self.check_features() tab = self.parent_muc - color = dump_tuple(get_theme().COLOR_REMOTE_USER) + theme = get_theme() + color = dump_tuple(theme.COLOR_REMOTE_USER) 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( - '\x19%(join_col)s}%(spec)s \x19%(color)s}%(nick)s\x19' - '%(info_col)s} joined the room' % { - 'nick': nick, - 'color': color, - 'spec': get_theme().CHAR_JOIN, - 'join_col': dump_tuple(get_theme().COLOR_JOIN_CHAR), - 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT) - }, - typ=2) + PersistentInfoMessage( + '\x19%(join_col)s}%(spec)s \x19%(color)s}%(nick)s\x19' + '%(info_col)s} joined the room' % { + 'nick': nick, + 'color': color, + 'spec': theme.CHAR_JOIN, + 'join_col': dump_tuple(theme.COLOR_JOIN_CHAR), + 'info_col': dump_tuple(theme.COLOR_INFORMATION_TEXT) + }, + ), + ) return self.core.tabs.current_tab is self def activate(self, reason=None): self.on = True if reason: - self.add_message(txt=reason, typ=2) + self.add_message(PersistentInfoMessage(reason)) def deactivate(self, reason=None): self.on = False if reason: - self.add_message(txt=reason, typ=2) + self.add_message(PersistentInfoMessage(reason)) def matching_names(self): - return [(3, safeJID(self.name).resource), (4, self.name)] + return [(3, self.jid.resource), (4, self.name)] def add_error(self, error_message): - error = '\x19%s}%s\x19o' % (dump_tuple(get_theme().COLOR_CHAR_NACK), + theme = get_theme() + error = '\x19%s}%s\x19o' % (dump_tuple(theme.COLOR_CHAR_NACK), error_message) self.add_message( - error, - highlight=True, - nickname='Error', - nick_color=get_theme().COLOR_ERROR_MSG, - typ=2) + Message( + error, + highlight=True, + nickname='Error', + nick_color=theme.COLOR_ERROR_MSG, + ), + ) self.core.refresh_window() diff --git a/poezio/tabs/rostertab.py b/poezio/tabs/rostertab.py index 9f609f61..18334c20 100644 --- a/poezio/tabs/rostertab.py +++ b/poezio/tabs/rostertab.py @@ -14,33 +14,36 @@ import ssl from functools import partial from os import getenv, path from pathlib import Path -from typing import Dict, Callable +from typing import Dict, Callable, Union + +from slixmpp import JID, InvalidJID +from slixmpp.exceptions import IqError, IqTimeout -from poezio import common from poezio import windows -from poezio.common import safeJID, shell_split +from poezio.common import shell_split from poezio.config import config from poezio.contact import Contact, Resource from poezio.decorators import refresh_wrapper from poezio.roster import RosterGroup, roster from poezio.theming import get_theme, dump_tuple -from poezio.decorators import command_args_parser +from poezio.decorators import command_args_parser, deny_anonymous from poezio.core.structs import Command, Completion from poezio.tabs import Tab +from poezio.ui.types import InfoMessage log = logging.getLogger(__name__) class RosterInfoTab(Tab): """ - A tab, splitted in two, containing the roster and infos + A tab, split in two, containing the roster and infos """ - plugin_commands = {} # type: Dict[str, Command] - plugin_keys = {} # type: Dict[str, Callable] + plugin_commands: Dict[str, Command] = {} + plugin_keys: Dict[str, Callable] = {} def __init__(self, core): Tab.__init__(self, core) - self.name = "Roster" + self._name = "Roster" self.v_separator = windows.VerticalSeparator() self.information_win = windows.TextWin() self.core.information_buffer.add_window(self.information_win) @@ -71,96 +74,54 @@ class RosterInfoTab(Tab): 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 a 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 a 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 them to' - ' allow you to see his presence, and allow them to' - ' see your presence.', - shortdesc='Add a 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>]|<group>', - desc='Add the given JID or selected line to the given group.', - shortdesc='Add a 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 a 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 a 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 a 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.') + '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>]|<group>', + desc='Add the given JID or selected line to the given group.', + shortdesc='Add a 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 a 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 a user from a group.', + completion=self.completion_groupremove) + 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( 'disconnect', self.command_disconnect, @@ -183,18 +144,6 @@ class RosterInfoTab(Tab): 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.') @@ -250,50 +199,40 @@ class RosterInfoTab(Tab): completion=self.completion_cert_fetch) @property - def selected_row(self): + def selected_row(self) -> Union[Contact, Resource]: return self.roster_win.get_selected_row() @command_args_parser.ignored - def command_certs(self): + async 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) + try: + iq = await self.core.xmpp.plugin['xep_0257'].get_certs(timeout=3) + except (IqError, IqTimeout): + 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') @command_args_parser.quoted(2, 1) - def command_cert_add(self, args): + async 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: @@ -319,8 +258,17 @@ class RosterInfoTab(Tab): else: management = True - self.core.xmpp.plugin['xep_0257'].add_cert( - name, crt, callback=cb, allow_management=management) + try: + await self.core.xmpp.plugin['xep_0257'].add_cert( + name, + crt, + allow_management=management + ) + self.core.information('Certificate added.', 'Info') + except (IqError, IqTimeout): + self.core.information('Unable to add the certificate.', + 'Error') + def completion_cert_add(self, the_input): """ @@ -336,76 +284,62 @@ class RosterInfoTab(Tab): return Completion(the_input.new_completion, ['true', 'false'], n) @command_args_parser.quoted(1) - def command_cert_disable(self, args): + async 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) + try: + await self.core.xmpp.plugin['xep_0257'].disable_cert(name) + self.core.information('Certificate disabled.', 'Info') + except (IqError, IqTimeout): + self.core.information('Unable to disable the certificate.', + 'Error') @command_args_parser.quoted(1) - def command_cert_revoke(self, args): + async 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) + try: + await self.core.xmpp.plugin['xep_0257'].revoke_cert(name) + self.core.information('Certificate revoked.', 'Info') + except (IqError, IqTimeout): + self.core.information('Unable to revoke the certificate.', + 'Error') @command_args_parser.quoted(2) - def command_cert_fetch(self, args): + async 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) + try: + iq = await self.core.xmpp.plugin['xep_0257'].get_certs() + except (IqError, IqTimeout): + 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') def completion_cert_fetch(self, the_input): """ @@ -426,110 +360,30 @@ class RosterInfoTab(Tab): 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' % { + message = '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] - """ - 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 - - def callback(iq): - if iq['type'] == 'error': - return self.core.information('Could not block %s.' % jid, - 'Error') - elif iq['type'] == 'result': - return self.core.information('Blocked %s.' % jid, 'Info') - - 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 Completion( - 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 Completion(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 + tab.add_message(InfoMessage(message)) @command_args_parser.ignored - def command_list_blocks(self): + async 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) + try: + iq = await self.core.xmpp.plugin['xep_0191'].get_blocked() + except (IqError, IqTimeout) as iq: + 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: - self.core.xmpp.connect() + s = 'No blocked JIDs.' + self.core.information(s, 'Info') @command_args_parser.ignored def command_disconnect(self): @@ -580,7 +434,9 @@ class RosterInfoTab(Tab): 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) + 0, roster_width + 1, self.core.information_buffer, + force=self.ui_config_changed) + self.ui_config_changed = False if display_contact_win: y = self.height - tab_win_height - contact_win_h - 1 avatar_width = contact_win_h * 2 @@ -652,83 +508,36 @@ class RosterInfoTab(Tab): self.core.information_buffer) self.refresh() + @deny_anonymous @command_args_parser.quoted(1) - def command_password(self, args): + async 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', 'Warning') - return - else: - jid = safeJID(args[0]).bare - if jid not in [jid for jid in roster.jids()]: - self.core.information('No subscription to deny', 'Warning') - 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 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') - + try: + await self.core.xmpp.plugin['xep_0077'].change_password( + args[0] + ) + self.core.information('Password updated', 'Account') + if config.getstr('password'): + config.silent_set('password', args[0]) + except (IqError, IqTimeout): + self.core.information('Unable to change the password', + 'Account') + + @deny_anonymous @command_args_parser.quoted(1, 1) - def command_name(self, args): + async 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 + try: + jid = JID(args[0]).bare + except InvalidJID: + self.core.information(f'Invalid JID: {args[0]}', 'Error') + return name = args[1] if len(args) == 2 else '' contact = roster[jid] @@ -740,15 +549,19 @@ class RosterInfoTab(Tab): if 'none' in groups: groups.remove('none') subscription = contact.subscription - self.core.xmpp.update_roster( - jid, - name=name, - groups=groups, - subscription=subscription, - callback=callback) + try: + await self.core.xmpp.update_roster( + jid, + name=name, + groups=groups, + subscription=subscription + ) + except (IqError, IqTimeout): + self.core.information('The name could not be set.', 'Error') + @deny_anonymous @command_args_parser.quoted(1, 1) - def command_groupadd(self, args): + async def command_groupadd(self, args): """ Add the specified JID to the specified group """ @@ -764,7 +577,11 @@ class RosterInfoTab(Tab): else: return self.core.command.help('groupadd') else: - jid = safeJID(args[0]).bare + try: + jid = JID(args[0]).bare + except InvalidJID: + self.core.information(f'Invalid JID: {args[0]}', 'Error') + return group = args[1] contact = roster[jid] @@ -787,28 +604,31 @@ class RosterInfoTab(Tab): 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) + try: + await self.core.xmpp.update_roster( + jid, + name=name, + groups=new_groups, + subscription=subscription, + ) + roster.update_contact_groups(jid) + except (IqError, IqTimeout): + self.core.information('The group could not be set.', 'Error') + @deny_anonymous @command_args_parser.quoted(3) - def command_groupmove(self, args): + async 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 + try: + jid = JID(args[0]).bare + except InvalidJID: + self.core.information(f'Invalid JID: {args[0]}', 'Error') + return group_from = args[1] group_to = args[2] @@ -845,30 +665,31 @@ class RosterInfoTab(Tab): new_groups.remove(group_from) name = contact.name subscription = contact.subscription + try: + await self.core.xmpp.update_roster( + jid, + name=name, + groups=new_groups, + subscription=subscription, + ) + roster.update_contact_groups(contact) + except (IqError, IqTimeout): + self.core.information('The group could not be set', 'Error') - def callback(iq): - if iq: - roster.update_contact_groups(contact) - else: - self.core.information('The group could not be set', 'Error') - log.debug('Error in groupmove:\n%s', iq) - - self.core.xmpp.update_roster( - jid, - name=name, - groups=new_groups, - subscription=subscription, - callback=callback) - + @deny_anonymous @command_args_parser.quoted(2) - def command_groupremove(self, args): + async 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 + try: + jid = JID(args[0]).bare + except InvalidJID: + self.core.information(f'Invalid JID: {args[0]}', 'Error') + return group = args[1] contact = roster[jid] @@ -890,39 +711,18 @@ class RosterInfoTab(Tab): new_groups.remove(group) name = contact.name subscription = contact.subscription + try: + self.core.xmpp.update_roster( + jid, + name=name, + groups=new_groups, + subscription=subscription, + ) + roster.update_contact_groups(jid) + except (IqError, IqTimeout): + self.core.information('The group could not be set') - 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', 'Error') - return - roster.remove(jid) - del roster[jid] - + @deny_anonymous @command_args_parser.quoted(0, 1) def command_import(self, args): """ @@ -948,9 +748,10 @@ class RosterInfoTab(Tab): log.error('Unable to correct a message', exc_info=True) return for jid in lines: - self.command_add(jid.lstrip('\n')) + self.core.command.command_add(jid.lstrip('\n')) self.core.information('Contacts imported from %s' % filepath, 'Info') + @deny_anonymous @command_args_parser.quoted(0, 1) def command_export(self, args): """ @@ -1045,49 +846,6 @@ class RosterInfoTab(Tab): 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 Completion(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', 'Warning') - 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() @@ -1128,7 +886,7 @@ class RosterInfoTab(Tab): Show or hide offline contacts """ option = 'roster_show_offline' - value = config.get(option) + value = config.getbool(option) success = config.silent_set(option, str(not value)) roster.modified() if not success: @@ -1272,15 +1030,6 @@ class RosterInfoTab(Tab): '%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 @@ -1306,7 +1055,7 @@ class RosterInfoTab(Tab): if isinstance(selected_row, Contact): jid = selected_row.bare_jid elif isinstance(selected_row, Resource): - jid = safeJID(selected_row.jid).bare + jid = JID(selected_row.jid).bare else: return self.on_slash() @@ -1388,8 +1137,11 @@ def jid_and_name_match(contact, txt): if not txt: return True txt = txt.lower() - if txt in safeJID(contact.bare_jid).bare.lower(): - return True + try: + if txt in JID(contact.bare_jid).bare.lower(): + return True + except InvalidJID: + pass if txt in contact.name.lower(): return True return False @@ -1402,9 +1154,12 @@ def jid_and_name_match_slow(contact, txt): """ if not txt: return True # Everything matches when search is empty - user = safeJID(contact.bare_jid).bare - if diffmatch(txt, user): - return True + try: + user = JID(contact.bare_jid).bare + if diffmatch(txt, user): + return True + except InvalidJID: + pass if contact.name and diffmatch(txt, contact.name): return True return False diff --git a/poezio/tabs/xmltab.py b/poezio/tabs/xmltab.py index c4a50df8..939af67d 100644 --- a/poezio/tabs/xmltab.py +++ b/poezio/tabs/xmltab.py @@ -10,7 +10,8 @@ log = logging.getLogger(__name__) import curses import os -from slixmpp.xmlstream import matcher +from slixmpp import JID, InvalidJID +from slixmpp.xmlstream import matcher, StanzaBase from slixmpp.xmlstream.tostring import tostring from slixmpp.xmlstream.stanzabase import ElementBase from xml.etree import ElementTree as ET @@ -21,17 +22,16 @@ from poezio import text_buffer from poezio import windows from poezio.xhtml import clean_text from poezio.decorators import command_args_parser, refresh_wrapper -from poezio.common import safeJID class MatchJID: - def __init__(self, jid, dest=''): + def __init__(self, jid: JID, dest: str = ''): self.jid = jid self.dest = dest - def match(self, xml): - from_ = safeJID(xml['from']) - to_ = safeJID(xml['to']) + def match(self, xml: StanzaBase): + from_ = xml['from'] + to_ = xml['to'] if self.jid.full == self.jid.bare: from_ = from_.bare to_ = to_.bare @@ -58,14 +58,14 @@ class XMLTab(Tab): def __init__(self, core): Tab.__init__(self, core) self.state = 'normal' - self.name = 'XMLTab' + 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.text_win = windows.TextWin() self.core_buffer.add_window(self.text_win) self.default_help_message = windows.HelpText("/ to enter a command") @@ -120,7 +120,7 @@ class XMLTab(Tab): 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.input = self.default_help_message # type: ignore self.key_func['^T'] = self.close self.key_func['^I'] = self.completion self.key_func["KEY_DOWN"] = self.on_scroll_down @@ -173,7 +173,7 @@ class XMLTab(Tab): self.text_win.toggle_lock() self.refresh() - def match_stanza(self, stanza): + def match_stanza(self, stanza) -> bool: for matcher_ in self.filters: if not matcher_.match(stanza): return False @@ -190,33 +190,36 @@ class XMLTab(Tab): self.command_filter_reset() @command_args_parser.raw - def command_filter_to(self, jid): + def command_filter_to(self, jid_str: str): """/filter_jid_to <jid>""" - jid_obj = safeJID(jid) - if not jid_obj: + try: + jid = JID(jid_str) + except InvalidJID: return self.core.information('Invalid JID: %s' % jid, 'Error') - self.update_filters(MatchJID(jid_obj, dest='to')) + self.update_filters(MatchJID(jid, dest='to')) self.refresh() @command_args_parser.raw - def command_filter_from(self, jid): + def command_filter_from(self, jid_str: str): """/filter_jid_from <jid>""" - jid_obj = safeJID(jid) - if not jid_obj: + try: + jid = JID(jid_str) + except InvalidJID: return self.core.information('Invalid JID: %s' % jid, 'Error') - self.update_filters(MatchJID(jid_obj, dest='from')) + self.update_filters(MatchJID(jid, dest='from')) self.refresh() @command_args_parser.raw - def command_filter_jid(self, jid): + def command_filter_jid(self, jid_str: str): """/filter_jid <jid>""" - jid_obj = safeJID(jid) - if not jid_obj: + try: + jid = JID(jid_str) + except InvalidJID: return self.core.information('Invalid JID: %s' % jid, 'Error') - self.update_filters(MatchJID(jid_obj)) + self.update_filters(MatchJID(jid)) self.refresh() @command_args_parser.quoted(1) @@ -229,7 +232,7 @@ class XMLTab(Tab): self.refresh() @command_args_parser.raw - def command_filter_xpath(self, xpath): + def command_filter_xpath(self, xpath: str): """/filter_xpath <xpath>""" try: self.update_filters( @@ -262,7 +265,10 @@ class XMLTab(Tab): else: xml = self.core_buffer.messages[:] text = '\n'.join( - ('%s %s %s' % (msg.str_time, msg.nickname, clean_text(msg.txt)) + ('%s %s %s' % ( + msg.time.strftime('%H:%M:%S'), + 'IN' if msg.incoming else 'OUT', + clean_text(msg.txt)) for msg in xml)) filename = os.path.expandvars(os.path.expanduser(args[0])) try: @@ -283,7 +289,7 @@ class XMLTab(Tab): self.input.do_command("/") # we add the slash @refresh_wrapper.always - def reset_help_message(self, _=None): + def reset_help_message(self, _=None) -> bool: if self.closed: return True if self.core.tabs.current_tab is self: @@ -291,10 +297,10 @@ class XMLTab(Tab): self.input = self.default_help_message return True - def on_scroll_up(self): + def on_scroll_up(self) -> bool: return self.text_win.scroll_up(self.text_win.height - 1) - def on_scroll_down(self): + def on_scroll_down(self) -> bool: return self.text_win.scroll_down(self.text_win.height - 1) @command_args_parser.ignored @@ -308,10 +314,11 @@ class XMLTab(Tab): self.refresh() self.core.doupdate() - def execute_slash_command(self, txt): + def execute_slash_command(self, txt: str) -> bool: if txt.startswith('/'): - self.input.key_enter() - self.execute_command(txt) + if isinstance(self.input, windows.CommandInput): + self.input.key_enter() + self.execute_command(txt) return self.reset_help_message() def completion(self): diff --git a/poezio/text_buffer.py b/poezio/text_buffer.py index 448adff3..bcee5989 100644 --- a/poezio/text_buffer.py +++ b/poezio/text_buffer.py @@ -8,98 +8,35 @@ Each text buffer can be linked to multiple windows, that will be rendered independently by their TextWins. """ +from __future__ import annotations + import logging -log = logging.getLogger(__name__) -from typing import Union, Optional, List, Tuple +from typing import ( + Dict, + List, + Optional, + TYPE_CHECKING, + Tuple, + Union, +) +from dataclasses import dataclass from datetime import datetime from poezio.config import config -from poezio.theming import get_theme, dump_tuple - - -class Message: - __slots__ = ('txt', 'nick_color', 'time', 'str_time', 'nickname', 'user', - 'identifier', 'highlight', 'me', 'old_message', 'revisions', - 'jid', 'ack') - - def __init__(self, - txt: str, - time: Optional[datetime], - nickname: Optional[str], - nick_color: Optional[Tuple], - history: bool, - user: Optional[str], - identifier: Optional[str], - str_time: Optional[str] = None, - highlight: bool = False, - old_message: Optional['Message'] = None, - revisions: int = 0, - jid: Optional[str] = None, - ack: int = 0) -> None: - """ - Create a new Message object with parameters, check for /me messages, - and delayed messages - """ - time = time if time is not None else datetime.now() - if txt.startswith('/me '): - me = True - txt = '\x19%s}%s\x19o' % (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 = '' - - self.txt = txt.replace('\t', ' ') + '\x19o' - self.nick_color = nick_color - self.time = time - self.str_time = str_time - self.nickname = nickname - self.user = user - self.identifier = identifier - self.highlight = highlight - self.me = me - self.old_message = old_message - self.revisions = revisions - self.jid = jid - self.ack = ack - - def _other_elems(self) -> str: - "Helper for the repr_message function" - acc = [] - fields = list(self.__slots__) - fields.remove('old_message') - for field in fields: - acc.append('%s=%s' % (field, repr(getattr(self, field)))) - return 'Message(%s, %s' % (', '.join(acc), 'old_message=') - - def __repr__(self) -> str: - """ - 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 = self._other_elems() - acc = [init] - next_message = self.old_message - rev = 1 - while next_message is not None: - acc.append(next_message._other_elems()) - next_message = next_message.old_message - rev += 1 - acc.append('None') - while rev: - acc.append(')') - rev -= 1 - return ''.join(acc) +from poezio.ui.types import ( + BaseMessage, + Message, + MucOwnJoinMessage, + MucOwnLeaveMessage, +) + +if TYPE_CHECKING: + from poezio.windows.text_win import TextWin + from poezio.user import User + from slixmpp import JID + + +log = logging.getLogger(__name__) class CorrectionError(Exception): @@ -110,6 +47,15 @@ class AckError(Exception): pass +@dataclass +class HistoryGap: + """Class representing a period of non-presence inside a MUC""" + leave_message: Optional[BaseMessage] + join_message: Optional[BaseMessage] + last_timestamp_before_leave: Optional[datetime] + first_timestamp_after_join: Optional[datetime] + + class TextBuffer: """ This class just keep trace of messages, in a list with various @@ -119,63 +65,133 @@ class TextBuffer: def __init__(self, messages_nb_limit: Optional[int] = None) -> None: if messages_nb_limit is None: - messages_nb_limit = config.get('max_messages_in_memory') - self._messages_nb_limit = messages_nb_limit # type: int + messages_nb_limit = config.getint('max_messages_in_memory') + self._messages_nb_limit: int = messages_nb_limit # Message objects - self.messages = [] # type: List[Message] + self.messages: List[BaseMessage] = [] + # COMPAT: Correction id -> Original message id. + self.correction_ids: Dict[str, str] = {} # 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 = [] + self._windows: List[TextWin] = [] def add_window(self, win) -> None: self._windows.append(win) + def find_last_gap_muc(self) -> Optional[HistoryGap]: + """Find the last known history gap contained in buffer""" + leave: Optional[Tuple[int, BaseMessage]] = None + join: Optional[Tuple[int, BaseMessage]] = None + for i, item in enumerate(reversed(self.messages)): + if isinstance(item, MucOwnLeaveMessage): + leave = (len(self.messages) - i - 1, item) + break + elif join and isinstance(item, MucOwnJoinMessage): + leave = (len(self.messages) - i - 1, item) + break + if isinstance(item, MucOwnJoinMessage): + join = (len(self.messages) - i - 1, item) + + last_timestamp = None + first_timestamp = datetime.now() + + # Identify the special case when we got disconnected from a chatroom + # without receiving or sending the relevant presence, therefore only + # having two joins with no leave, and messages in the middle. + if leave and join and isinstance(leave[1], MucOwnJoinMessage): + for i in range(join[0] - 1, leave[0], - 1): + if isinstance(self.messages[i], Message): + leave = ( + i, + self.messages[i] + ) + last_timestamp = self.messages[i].time + break + # If we have a normal gap but messages inbetween, it probably + # already has history, so abort there without returning it. + if join and leave: + for i in range(leave[0] + 1, join[0], 1): + if isinstance(self.messages[i], Message): + return None + elif not (join or leave): + return None + + # If a leave message is found, get the last Message timestamp + # before it. + if leave is None: + leave_msg = None + elif last_timestamp is None: + leave_msg = leave[1] + for i in range(leave[0], 0, -1): + if isinstance(self.messages[i], Message): + last_timestamp = self.messages[i].time + break + else: + leave_msg = leave[1] + # If a join message is found, get the first Message timestamp + # after it, or the current time. + if join is None: + join_msg = None + else: + join_msg = join[1] + for i in range(join[0], len(self.messages)): + msg = self.messages[i] + if isinstance(msg, Message) and msg.time < first_timestamp: + first_timestamp = msg.time + break + return HistoryGap( + leave_message=leave_msg, + join_message=join_msg, + last_timestamp_before_leave=last_timestamp, + first_timestamp_after_join=first_timestamp, + ) + + def get_gap_index(self, gap: HistoryGap) -> Optional[int]: + """Find the first index to insert into inside a gap""" + if gap.leave_message is None: + return 0 + for i, msg in enumerate(self.messages): + if msg is gap.leave_message: + return i + 1 + return None + + def add_history_messages(self, messages: List[BaseMessage], gap: Optional[HistoryGap] = None) -> None: + """Insert history messages at their correct place """ + index = 0 + new_index = None + if gap is not None: + new_index = self.get_gap_index(gap) + if new_index is None: # Not sure what happened, abort + return + index = new_index + for message in messages: + self.messages.insert(index, message) + index += 1 + log.debug('inserted message: %s', message) + for window in self._windows: # make the associated windows + window.rebuild_everything(self) + @property - def last_message(self) -> Optional[Message]: + def last_message(self) -> Optional[BaseMessage]: return self.messages[-1] if self.messages else None - def add_message(self, - txt: str, - time: Optional[datetime] = None, - nickname: Optional[str] = None, - nick_color: Optional[Tuple] = None, - history: bool = False, - user: Optional[str] = None, - highlight: bool = False, - identifier: Optional[str] = None, - str_time: Optional[str] = None, - jid: Optional[str] = None, - ack: int = 0) -> int: + def add_message(self, msg: BaseMessage): """ Create a message and add it to the text buffer """ - msg = 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 = 0 - show_timestamps = config.get('show_timestamps') - nick_size = config.get('max_nick_length') + show_timestamps = config.getbool('show_timestamps') + nick_size = config.getbool('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 == 0: @@ -185,35 +201,42 @@ class TextBuffer: return min(ret_val, 1) - def _find_message(self, old_id: str) -> int: + def _find_message(self, orig_id: str) -> Tuple[str, int]: """ Find a message in the text buffer from its message id """ + # When looking for a message, ensure the id doesn't appear in a + # message we've removed from our message list. If so return the index + # of the corresponding id for the original message instead. + orig_id = self.correction_ids.get(orig_id, orig_id) + for i in range(len(self.messages) - 1, -1, -1): msg = self.messages[i] - if msg.identifier == old_id: - return i - return -1 + if msg.identifier == orig_id: + return (orig_id, i) + return (orig_id, -1) - def ack_message(self, old_id: str, jid: str) -> Union[None, bool, Message]: + def ack_message(self, old_id: str, jid: JID) -> Union[None, bool, Message]: """Mark a message as acked""" return self._edit_ack(1, old_id, jid) def nack_message(self, error: str, old_id: str, - jid: str) -> Union[None, bool, Message]: + jid: JID) -> Union[None, bool, Message]: """Mark a message as errored""" return self._edit_ack(-1, old_id, jid, append=error) - def _edit_ack(self, value: int, old_id: str, jid: str, + def _edit_ack(self, value: int, old_id: str, jid: JID, append: str = '') -> Union[None, bool, Message]: """ Edit the ack status of a message, and optionally append some text. """ - i = self._find_message(old_id) + _, i = self._find_message(old_id) if i == -1: return None msg = self.messages[i] + if not isinstance(msg, Message): + return None if msg.ack == 1: # Message was already acked return False if msg.jid != jid: @@ -227,29 +250,35 @@ class TextBuffer: def modify_message(self, txt: str, - old_id: str, + orig_id: str, new_id: str, highlight: bool = False, time: Optional[datetime] = None, - user: Optional[str] = None, - jid: Optional[str] = None): + user: Optional[User] = None, + jid: Optional[JID] = None) -> Message: """ Correct a message in a text buffer. + + Version 1.1.0 of Last Message Correction (0308) added clarifications + that break the way poezio handles corrections. Instead of linking + corrections to the previous correction/message as we were doing, we + are now required to link all corrections to the original messages. """ - i = self._find_message(old_id) + orig_id, i = self._find_message(orig_id) if i == -1: log.debug( 'Message %s not found in text_buffer, abort replacement.', - old_id) + orig_id) raise CorrectionError("nothing to replace") msg = self.messages[i] - + if not isinstance(msg, Message): + raise CorrectionError('Wrong message type') if msg.user and msg.user is not user: raise CorrectionError("Different users") - elif len(msg.str_time) > 8: # ugly + elif msg.delayed: raise CorrectionError("Delayed message") elif not msg.user and (msg.jid is None or jid is None): raise CorrectionError('Could not check the ' @@ -257,29 +286,44 @@ class TextBuffer: 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)) + 'sent by the same fullJID' % (orig_id, new_id)) if not time: - time = msg.time + time = datetime.now() + + self.correction_ids[new_id] = orig_id message = Message( - txt, - time, - msg.nickname, - msg.nick_color, - False, - msg.user, - new_id, + txt=txt, + time=time, + nickname=msg.nickname, + nick_color=msg.nick_color, + user=msg.user, + identifier=orig_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) + log.debug('Replacing message %s with %s.', orig_id, new_id) return message def del_window(self, win) -> None: self._windows.remove(win) + def find_last_message(self) -> Optional[Message]: + """Find the last real message received in this buffer""" + for message in reversed(self.messages): + if isinstance(message, Message): + return message + return None + + def find_first_message(self) -> Optional[Message]: + """Find the first real message received in this buffer""" + for message in self.messages: + if isinstance(message, Message): + return message + return None + 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 index db1ccb39..187d07c5 100755 --- a/poezio/theming.py +++ b/poezio/theming.py @@ -1,9 +1,10 @@ +#!/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. +# it under the terms of the GPL-3.0+ license. See the COPYING file. """ Define the variables (colors and some other stuff) that are used when drawing the interface. @@ -73,11 +74,11 @@ except ImportError: import curses import functools -import os -from typing import Dict, List, Union, Tuple, Optional +from typing import Dict, List, Union, Tuple, Optional, cast from pathlib import Path from os import path from poezio import colors, xdg +from datetime import datetime from importlib import machinery finder = machinery.PathFinder() @@ -143,6 +144,14 @@ class Theme: return sub_mapping[sub] if sub == keep else '' return sub_mapping.get(sub, '') + # Short date format (only show time) + SHORT_TIME_FORMAT = '%H:%M:%S' + SHORT_TIME_FORMAT_LENGTH = len(datetime.now().strftime(SHORT_TIME_FORMAT)) + + # Long date format (show date and time) + LONG_TIME_FORMAT = '%Y-%m-%d %H:%M:%S' + LONG_TIME_FORMAT_LENGTH = len(datetime.now().strftime(LONG_TIME_FORMAT)) + # Message text color COLOR_NORMAL_TEXT = (-1, -1) COLOR_INFORMATION_TEXT = (5, -1) # TODO @@ -178,12 +187,13 @@ class Theme: CHAR_CHATSTATE_COMPOSING = 'X' CHAR_CHATSTATE_PAUSED = 'p' - # These characters are used for the affiliation in the user list - # in a MUC + # These characters are used for the affiliation wherever needed, e.g., in + # the user list in a MUC, or when displaying affiliation lists. CHAR_AFFILIATION_OWNER = '~' CHAR_AFFILIATION_ADMIN = '&' CHAR_AFFILIATION_MEMBER = '+' CHAR_AFFILIATION_NONE = '-' + CHAR_AFFILIATION_OUTCAST = '!' # XML Tab CHAR_XML_IN = 'IN ' @@ -198,7 +208,7 @@ class Theme: COLOR_REVISIONS_MESSAGE = (3, -1, 'b') # Color for various important text. For example the "?" before JIDs in - # the roster that require an user action. + # the roster that require a user action. COLOR_IMPORTANT_TEXT = (3, 5, 'b') # Separators @@ -224,6 +234,15 @@ class Theme: COLOR_TAB_ATTENTION = (7, 1) COLOR_TAB_DISCONNECTED = (7, 8) + # If autocolor_tab_names is set to true, the following modes are used to + # distinguish tabs with normal and important messages. + MODE_TAB_NORMAL = '' + MODE_TAB_IMPORTANT = 'r' # reverse video mode + + # This is the mode used for the tab name in the info bar of MUC and 1:1 + # chat tabs. + MODE_TAB_NAME = 'r' + COLOR_VERTICAL_TAB_NORMAL = (4, -1) COLOR_VERTICAL_TAB_NONEMPTY = (4, -1) COLOR_VERTICAL_TAB_JOINED = (82, -1) @@ -281,7 +300,7 @@ class Theme: (224, -1), (225, -1), (226, -1), (227, -1)] # XEP-0392 consistent color generation palette placeholder # it’s generated on first use when accessing the ccg_palette property - CCG_PALETTE = None # type: Optional[Dict[float, int]] + CCG_PALETTE: Optional[Dict[float, int]] = None CCG_Y = 0.5**0.45 # yapf: enable @@ -315,7 +334,9 @@ class Theme: COLOR_COLUMN_HEADER_SEL = (4, 36) # Strings for special messages (like join, quit, nick change, etc) - # Special messages + CHAR_BEFORE_NICK_ME = '* ' + CHAR_AFTER_NICK_ME = ' ' + CHAR_AFTER_NICK = '> ' CHAR_JOIN = '--->' CHAR_QUIT = '<---' CHAR_KICK = '-!-' @@ -358,7 +379,7 @@ class Theme: # Info messages color (the part before the ">") INFO_COLORS = { 'info': (5, -1), - 'error': (16, 1), + 'error': (9, 7, 'b'), 'warning': (1, -1), 'roster': (2, -1), 'help': (10, -1), @@ -371,7 +392,7 @@ class Theme: } @property - def ccg_palette(self): + def ccg_palette(self) -> Optional[Dict[float, int]]: prepare_ccolor_palette(self) return self.CCG_PALETTE @@ -383,8 +404,7 @@ theme = Theme() # 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 = { -} # type: Dict[Union[Tuple[int, int], Tuple[int, int, str]], int] +curses_colors_dict: Dict[Union[Tuple[int, int], Tuple[int, int, str]], int] = {} # yapf: disable @@ -408,7 +428,7 @@ table_256_to_16 = [ ] # yapf: enable -load_path = [] # type: List[str] +load_path: List[str] = [] def color_256_to_16(color): @@ -441,13 +461,14 @@ def to_curses_attr( returns a valid curses attr that can be passed directly to attron() or attroff() """ # extract the color from that tuple + colors: Union[Tuple[int, int], Tuple[int, int, str]] if len(color_tuple) == 3: colors = (color_tuple[0], color_tuple[1]) else: colors = color_tuple bold = False - if curses.COLORS != 256: + 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])) @@ -466,7 +487,7 @@ def to_curses_attr( curses_colors_dict[colors] = pair curses_pair = curses.color_pair(pair) if len(color_tuple) == 3: - additional_val = color_tuple[2] + _, _, additional_val = cast(Tuple[int, int, str], color_tuple) if 'b' in additional_val or bold is True: curses_pair = curses_pair | curses.A_BOLD if 'u' in additional_val: @@ -476,6 +497,8 @@ def to_curses_attr( curses, 'A_ITALIC') else curses.A_REVERSE) if 'a' in additional_val: curses_pair = curses_pair | curses.A_BLINK + if 'r' in additional_val: + curses_pair = curses_pair | curses.A_REVERSE return curses_pair @@ -498,7 +521,7 @@ def update_themes_dir(option: Optional[str] = None, load_path.append(default_dir) # import from the user-defined prefs - themes_dir_str = config.get('themes_dir') + themes_dir_str = config.getstr('themes_dir') themes_dir = Path(themes_dir_str).expanduser( ) if themes_dir_str else xdg.DATA_HOME / 'themes' try: @@ -544,7 +567,7 @@ def prepare_ccolor_palette(theme: Theme) -> None: def reload_theme() -> Optional[str]: - theme_name = config.get('theme') + theme_name = config.getstr('theme') global theme if theme_name == 'default' or not theme_name.strip(): theme = Theme() @@ -552,10 +575,10 @@ def reload_theme() -> Optional[str]: new_theme = None exc = None try: - loader = finder.find_module(theme_name, load_path) - if not loader: + spec = finder.find_spec(theme_name, path=load_path) + if not spec or not spec.loader: return 'Failed to load the theme %s' % theme_name - new_theme = loader.load_module() + new_theme = spec.loader.load_module(theme_name) except Exception as e: log.error('Failed to load the theme %s', theme_name, exc_info=True) exc = e @@ -564,7 +587,7 @@ def reload_theme() -> Optional[str]: return 'Failed to load theme: %s' % exc if hasattr(new_theme, 'theme'): - theme = new_theme.theme + theme = new_theme.theme # type: ignore prepare_ccolor_palette(theme) return None return 'No theme present in the theme file' diff --git a/poezio/timed_events.py b/poezio/timed_events.py index cd7659e2..314ed76c 100644 --- a/poezio/timed_events.py +++ b/poezio/timed_events.py @@ -3,7 +3,7 @@ # 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. +# it under the terms of the GPL-3.0+ license. See the COPYING file. """ Timed events are the standard way to schedule events for later in poezio. @@ -32,11 +32,11 @@ class DelayedEvent: :param function callback: The handler that will be executed. :param args: Optional arguments passed to the handler. """ - self.callback = callback # type: Callable - self.args = args # type: Tuple[Any, ...] - self.delay = delay # type: Union[int, float] + self.callback: Callable = callback + self.args: Tuple[Any, ...] = args + self.delay: Union[int, float] = delay # An asyncio handler, as returned by call_later() or call_at() - self.handler = None # type: Optional[Handle] + self.handler: Optional[Handle] = None class TimedEvent(DelayedEvent): diff --git a/poezio/types.py b/poezio/types.py new file mode 100644 index 00000000..8d727f46 --- /dev/null +++ b/poezio/types.py @@ -0,0 +1,8 @@ +"""Poezio type stuff""" + +try: + from typing import TypedDict +except ImportError: + from typing_extensions import TypedDict + +__all__ = ['TypedDict'] diff --git a/poezio/ui/__init__.py b/poezio/ui/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/poezio/ui/__init__.py diff --git a/poezio/ui/consts.py b/poezio/ui/consts.py new file mode 100644 index 00000000..91f19a82 --- /dev/null +++ b/poezio/ui/consts.py @@ -0,0 +1,4 @@ +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\x1A' diff --git a/poezio/windows/funcs.py b/poezio/ui/funcs.py index 22977374..023432ee 100644 --- a/poezio/windows/funcs.py +++ b/poezio/ui/funcs.py @@ -4,14 +4,14 @@ Standalone functions used by the modules import string from typing import Optional, List -from poezio.windows.base_wins import FORMAT_CHAR, format_chars +from poezio.ui.consts import FORMAT_CHAR, FORMAT_CHARS DIGITS = string.digits + '-' def find_first_format_char(text: str, chars: str = None) -> int: - to_find = chars or format_chars + to_find = chars or FORMAT_CHARS pos = -1 for char in to_find: p = text.find(char) @@ -22,12 +22,14 @@ def find_first_format_char(text: str, return pos -def truncate_nick(nick: Optional[str], size=10) -> Optional[str]: +def truncate_nick(nick: Optional[str], size=10) -> str: if size < 1: size = 1 - if nick and len(nick) > size: - return nick[:size] + '…' - return nick + if nick: + if len(nick) > size: + return nick[:size] + '…' + return nick + return '' def parse_attrs(text: str, previous: Optional[List[str]] = None) -> List[str]: diff --git a/poezio/ui/render.py b/poezio/ui/render.py new file mode 100644 index 00000000..aad482b5 --- /dev/null +++ b/poezio/ui/render.py @@ -0,0 +1,280 @@ +from __future__ import annotations + +import curses + +from datetime import ( + datetime, + date, +) +from functools import singledispatch +from math import ceil, log10 +from typing import ( + List, + Optional, + Tuple, + TYPE_CHECKING, +) + +from poezio import poopt +from poezio.theming import ( + get_theme, +) +from poezio.ui.consts import ( + FORMAT_CHAR, +) +from poezio.ui.funcs import ( + truncate_nick, + parse_attrs, +) +from poezio.ui.types import ( + BaseMessage, + Message, + StatusMessage, + UIMessage, + XMLLog, +) + +if TYPE_CHECKING: + from poezio.windows import Win + +# msg is a reference to the corresponding Message object. text_start and +# text_end are the position delimiting the text in this line. +class Line: + __slots__ = ('msg', 'start_pos', 'end_pos', 'prepend') + + def __init__(self, msg: BaseMessage, start_pos: int, end_pos: int, prepend: str) -> None: + self.msg = msg + self.start_pos = start_pos + self.end_pos = end_pos + self.prepend = prepend + + def __repr__(self): + return '(%s, %s)' % (self.start_pos, self.end_pos) + + +LinePos = Tuple[int, int] + + +def generate_lines(lines: List[LinePos], msg: BaseMessage, default_color: str = '') -> List[Line]: + line_objects = [] + attrs: List[str] = [] + prepend = default_color if default_color else '' + for line in lines: + saved = Line( + msg=msg, + start_pos=line[0], + end_pos=line[1], + prepend=prepend) + attrs = parse_attrs(msg.txt[line[0]:line[1]], attrs) + if attrs: + prepend = FORMAT_CHAR + FORMAT_CHAR.join(attrs) + else: + if default_color: + prepend = default_color + else: + prepend = '' + line_objects.append(saved) + return line_objects + + +@singledispatch +def build_lines(msg: BaseMessage, width: int, timestamp: bool, nick_size: int = 10) -> List[Line]: + offset = msg.compute_offset(timestamp, nick_size) + lines = poopt.cut_text(msg.txt, width - offset - 1) + return generate_lines(lines, msg, default_color='') + + +@build_lines.register(type(None)) +def build_separator(*args, **kwargs): + return [None] + + +@build_lines.register(Message) +def build_message(msg: Message, width: int, timestamp: bool, nick_size: int = 10) -> List[Line]: + """ + Build a list of lines from this message. + """ + txt = msg.txt + if not txt: + return [] + offset = msg.compute_offset(timestamp, nick_size) + lines = poopt.cut_text(txt, width - offset - 1) + generated_lines = generate_lines(lines, msg, default_color='') + return generated_lines + + +@build_lines.register(StatusMessage) +def build_status(msg: StatusMessage, width: int, timestamp: bool, nick_size: int = 10) -> List[Line]: + msg.rebuild() + offset = msg.compute_offset(timestamp, nick_size) + lines = poopt.cut_text(msg.txt, width - offset - 1) + return generate_lines(lines, msg, default_color='') + + +@build_lines.register(XMLLog) +def build_xmllog(msg: XMLLog, width: int, timestamp: bool, nick_size: int = 10) -> List[Line]: + offset = msg.compute_offset(timestamp, nick_size) + lines = poopt.cut_text(msg.txt, width - offset - 1) + return generate_lines(lines, msg, default_color='') + + +@singledispatch +def write_pre(msg: BaseMessage, win: Win, with_timestamps: bool, nick_size: int) -> int: + """Write the part before text (only the timestamp)""" + if with_timestamps: + return PreMessageHelpers.write_time(win, False, msg.time) + return 0 + + +@write_pre.register(UIMessage) +def write_pre_uimessage(msg: UIMessage, win: Win, with_timestamps: bool, nick_size: int) -> int: + """ Write the prefix of a ui message log + - timestamp (short or long) + - level + """ + color: Optional[Tuple] + offset = 0 + if with_timestamps: + offset += PreMessageHelpers.write_time(win, False, msg.time) + + if not msg.level: # not a message, nothing to do afterwards + return offset + + level = truncate_nick(msg.level, nick_size) + offset += poopt.wcswidth(level) + color = msg.color + PreMessageHelpers.write_nickname(win, level, color, False) + win.addstr('> ') + offset += 2 + return offset + + +@write_pre.register(Message) +def write_pre_message(msg: Message, win: Win, with_timestamps: bool, nick_size: int) -> int: + """Write the part before the body: + - timestamp (short or long) + - ack/nack + - nick (with a "* " for /me) + - LMC number if present + """ + color: Optional[Tuple] + offset = 0 + if with_timestamps: + offset += PreMessageHelpers.write_time(win, msg.history, msg.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 += PreMessageHelpers.write_ack(win) + else: + offset += PreMessageHelpers.write_nack(win) + theme = get_theme() + if msg.me: + with win.colored_text(color=theme.COLOR_ME_MESSAGE): + win.addstr(theme.CHAR_BEFORE_NICK_ME) + PreMessageHelpers.write_nickname(win, nick, color, msg.highlight) + offset += PreMessageHelpers.write_revisions(win, msg) + win.addstr(theme.CHAR_AFTER_NICK_ME) + offset += len(theme.CHAR_BEFORE_NICK_ME) + len(theme.CHAR_AFTER_NICK_ME) + else: + PreMessageHelpers.write_nickname(win, nick, color, msg.highlight) + offset += PreMessageHelpers.write_revisions(win, msg) + win.addstr(theme.CHAR_AFTER_NICK) + offset += len(theme.CHAR_AFTER_NICK) + return offset + + +@write_pre.register(XMLLog) +def write_pre_xmllog(msg: XMLLog, win: Win, with_timestamps: bool, nick_size: int) -> int: + """Write the part before the stanza (timestamp + IN/OUT)""" + offset = 0 + if with_timestamps: + offset += 1 + PreMessageHelpers.write_time(win, False, msg.time) + theme = get_theme() + if msg.incoming: + char = theme.CHAR_XML_IN + color = theme.COLOR_XML_IN + else: + char = theme.CHAR_XML_OUT + color = theme.COLOR_XML_OUT + nick = truncate_nick(char, nick_size) + offset += poopt.wcswidth(nick) + PreMessageHelpers.write_nickname(win, char, color) + win.addstr(' ') + return offset + +class PreMessageHelpers: + + @staticmethod + def write_revisions(buffer: Win, msg: Message) -> int: + if msg.revisions: + color = get_theme().COLOR_REVISIONS_MESSAGE + with buffer.colored_text(color=color): + buffer.addstr('%d' % msg.revisions) + return ceil(log10(msg.revisions + 1)) + return 0 + + @staticmethod + def write_ack(buffer: Win) -> int: + theme = get_theme() + color = theme.COLOR_CHAR_ACK + with buffer.colored_text(color=color): + buffer.addstr(theme.CHAR_ACK_RECEIVED) + buffer.addstr(' ') + return poopt.wcswidth(theme.CHAR_ACK_RECEIVED) + 1 + + @staticmethod + def write_nack(buffer: Win) -> int: + theme = get_theme() + color = theme.COLOR_CHAR_NACK + with buffer.colored_text(color=color): + buffer.addstr(theme.CHAR_NACK) + buffer.addstr(' ') + return poopt.wcswidth(theme.CHAR_NACK) + 1 + + @staticmethod + def write_nickname(buffer: Win, nickname: str, color, highlight=False) -> None: + """ + Write the nickname, using the user's color + and return the number of written characters + """ + if not nickname: + return + attr = None + if highlight: + hl_color = get_theme().COLOR_HIGHLIGHT_NICK + if hl_color == "reverse": + attr = curses.A_REVERSE + else: + color = hl_color + with buffer.colored_text(color=color, attr=attr): + buffer.addstr(nickname) + + @staticmethod + def write_time(buffer: Win, history: bool, time: datetime) -> int: + """ + Write the date on the yth line of the window + """ + if time: + theme = get_theme() + if history and time.date() != date.today(): + format = theme.LONG_TIME_FORMAT + else: + format = theme.SHORT_TIME_FORMAT + time_str = time.strftime(format) + color = theme.COLOR_TIME_STRING + with buffer.colored_text(color=color): + buffer.addstr(time_str) + buffer.addstr(' ') + return poopt.wcswidth(time_str) + 1 + return 0 diff --git a/poezio/ui/types.py b/poezio/ui/types.py new file mode 100644 index 00000000..27ccbd62 --- /dev/null +++ b/poezio/ui/types.py @@ -0,0 +1,260 @@ +from __future__ import annotations + +from datetime import datetime +from math import ceil, log10 +from typing import Optional, Tuple, Dict, Any, Callable + +from slixmpp import JID + +from poezio import poopt +from poezio.theming import dump_tuple, get_theme +from poezio.ui.funcs import truncate_nick +from poezio.user import User + + +class BaseMessage: + """Base class for all ui-related messages""" + __slots__ = ('txt', 'time', 'identifier') + + txt: str + identifier: str + time: datetime + + def __init__(self, txt: str, identifier: str = '', time: Optional[datetime] = None): + self.txt = txt + self.identifier = identifier + if time is not None: + self.time = time + else: + self.time = datetime.now() + + def compute_offset(self, with_timestamps: bool, nick_size: int) -> int: + """Compute the offset of the message""" + theme = get_theme() + return theme.SHORT_TIME_FORMAT_LENGTH + 1 + + +class EndOfArchive(BaseMessage): + """Marker added to a buffer when we reach the end of a MAM archive""" + + +class InfoMessage(BaseMessage): + """Information message""" + def __init__(self, txt: str, identifier: str = '', time: Optional[datetime] = None): + txt = ('\x19%s}' % dump_tuple(get_theme().COLOR_INFORMATION_TEXT)) + txt + super().__init__(txt=txt, identifier=identifier, time=time) + + +class UIMessage(BaseMessage): + """Message displayed through poezio UI""" + __slots__ = ('level', 'color') + level: str + color: Optional[Tuple] + + def __init__(self, txt: str, level: str): + BaseMessage.__init__(self, txt=txt) + self.level = level.capitalize() + colors = get_theme().INFO_COLORS + self.color = colors.get(level.lower(), colors.get('default', None)) + + def compute_offset(self, with_timestamps: bool, nick_size: int) -> int: + """Compute the x-position at which the message should be printed""" + offset = 0 + theme = get_theme() + if with_timestamps: + offset += 1 + theme.SHORT_TIME_FORMAT_LENGTH + level = self.level + if not level: # not a message, nothing to do afterwards + return offset + level = truncate_nick(level, nick_size) or '' + offset += poopt.wcswidth(level) + offset += 2 + return offset + + +class LoggableTrait: + """Trait for classes of messages that should go through the logger""" + pass + + +class PersistentInfoMessage(InfoMessage, LoggableTrait): + """Information message thatt will be logged""" + pass + + +class MucOwnLeaveMessage(InfoMessage, LoggableTrait): + """Status message displayed on our room leave/kick/ban""" + + +class MucOwnJoinMessage(InfoMessage, LoggableTrait): + """Status message displayed on our room join""" + + +class XMLLog(BaseMessage): + """XML Log message""" + __slots__ = ('incoming') + incoming: bool + + def __init__( + self, + txt: str, + incoming: bool, + ): + BaseMessage.__init__( + self, + txt=txt, + ) + self.incoming = incoming + + def compute_offset(self, with_timestamps: bool, nick_size: int) -> int: + offset = 0 + theme = get_theme() + if with_timestamps: + offset += 1 + theme.SHORT_TIME_FORMAT_LENGTH + if self.incoming: + nick = theme.CHAR_XML_IN + else: + nick = theme.CHAR_XML_OUT + nick = truncate_nick(nick, nick_size) or '' + offset += 1 + len(nick) + return offset + + +class StatusMessage(BaseMessage): + """A dynamically formatted status message""" + __slots__ = ('format_string', 'format_args') + format_string: str + format_args: Dict[str, Callable[[], Any]] + + def __init__(self, format_string: str, format_args: dict): + BaseMessage.__init__( + self, + txt='', + ) + self.format_string = format_string + self.format_args = format_args + self.rebuild() + + def rebuild(self): + real_args = {} + for key, func in self.format_args.items(): + real_args[key] = func() + self.txt = self.format_string.format(**real_args) + + +class Message(BaseMessage, LoggableTrait): + __slots__ = ('nick_color', 'nickname', 'user', 'delayed', 'history', + 'highlight', 'me', 'old_message', 'revisions', + 'jid', 'ack') + nick_color: Optional[Tuple] + nickname: Optional[str] + user: Optional[User] + delayed: bool + history: bool + highlight: bool + me: bool + old_message: Optional[Message] + revisions: int + jid: Optional[JID] + ack: int + + def __init__(self, + txt: str, + nickname: Optional[str], + time: Optional[datetime] = None, + nick_color: Optional[Tuple] = None, + delayed: bool = False, + history: bool = False, + user: Optional[User] = None, + identifier: Optional[str] = '', + highlight: bool = False, + old_message: Optional[Message] = None, + revisions: int = 0, + jid: Optional[JID] = None, + ack: int = 0) -> None: + """ + Create a new Message object with parameters, check for /me messages, + and delayed messages + """ + BaseMessage.__init__( + self, + txt=txt.replace('\t', ' ') + '\x19o', + identifier=identifier or '', + time=time, + ) + if txt.startswith('/me '): + me = True + txt = '\x19%s}%s\x19o' % (dump_tuple(get_theme().COLOR_ME_MESSAGE), + txt[4:]) + else: + me = False + self.txt = txt + self.delayed = delayed or history + self.history = history + self.nickname = nickname + self.nick_color = nick_color + self.user = user + self.highlight = highlight + self.me = me + self.old_message = old_message + self.revisions = revisions + self.jid = jid + self.ack = ack + + def _other_elems(self) -> str: + "Helper for the repr_message function" + acc = [] + fields = list(self.__slots__) + fields.remove('old_message') + for field in fields: + acc.append('%s=%s' % (field, repr(getattr(self, field)))) + return 'Message(%s, %s' % (', '.join(acc), 'old_message=') + + def __repr__(self) -> str: + """ + 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 = self._other_elems() + acc = [init] + next_message = self.old_message + rev = 1 + while next_message is not None: + acc.append(next_message._other_elems()) + next_message = next_message.old_message + rev += 1 + acc.append('None') + while rev: + acc.append(')') + rev -= 1 + return ''.join(acc) + + def compute_offset(self, with_timestamps: bool, nick_size: int) -> int: + """Compute the x-position at which the message should be printed""" + offset = 0 + theme = get_theme() + if with_timestamps: + if self.history: + offset += 1 + theme.LONG_TIME_FORMAT_LENGTH + else: + offset += 1 + theme.SHORT_TIME_FORMAT_LENGTH + + if not self.nickname: # not a message, nothing to do afterwards + return offset + + nick = truncate_nick(self.nickname, nick_size) or '' + offset += poopt.wcswidth(nick) + if self.ack: + theme = get_theme() + if self.ack > 0: + offset += poopt.wcswidth(theme.CHAR_ACK_RECEIVED) + 1 + else: + offset += poopt.wcswidth(theme.CHAR_NACK) + 1 + if self.me: + offset += 3 + else: + offset += 2 + if self.revisions: + offset += ceil(log10(self.revisions + 1)) + return offset diff --git a/poezio/user.py b/poezio/user.py index 655eb0de..602ee2c8 100644 --- a/poezio/user.py +++ b/poezio/user.py @@ -3,16 +3,15 @@ # 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. +# it under the terms of the GPL-3.0+ license. See the COPYING file. """ Define the user class. -An user is a MUC participant, not a roster contact (see contact.py) +A user is a MUC participant, not a roster contact (see contact.py) """ import logging from datetime import timedelta, datetime from hashlib import md5 -from random import choice from typing import Optional, Tuple from poezio import xhtml, colors @@ -26,7 +25,7 @@ ROLE_DICT = {'': 0, 'none': 0, 'visitor': 1, 'participant': 2, 'moderator': 3} class User: """ - keep trace of an user in a Room + keep track of a user in a Room """ __slots__ = ('last_talked', 'jid', 'chatstate', 'affiliation', 'show', 'status', 'role', 'nick', 'color') @@ -38,28 +37,28 @@ class User: status: str, role: str, jid: JID, - deterministic=True, color='') -> None: # The oldest possible time - self.last_talked = datetime(1, 1, 1) # type: datetime + self.last_talked: datetime = datetime(1, 1, 1) self.update(affiliation, show, status, role) self.change_nick(nick) - self.jid = jid # type: JID - self.chatstate = None # type: Optional[str] - self.color = (1, 1) # type: Tuple[int, int] + self.jid: JID = jid + self.chatstate: Optional[str] = None + self.color: Tuple[int, int] = (1, 1) if color != '': - self.change_color(color, deterministic) + self.change_color(color) else: - if deterministic: - self.set_deterministic_color() - else: - self.color = choice(get_theme().LIST_COLOR_NICKNAMES) + self.set_deterministic_color() - def set_deterministic_color(self): + def set_deterministic_color(self) -> None: theme = get_theme() if theme.ccg_palette: # use XEP-0392 CCG - fg_color = colors.ccg_text_to_color(theme.ccg_palette, self.nick) + if self.jid and self.jid.domain: + input_ = self.jid.bare + else: + input_ = self.nick + fg_color = colors.ccg_text_to_color(theme.ccg_palette, input_) self.color = fg_color, -1 else: mod = len(theme.LIST_COLOR_NICKNAMES) @@ -78,14 +77,10 @@ class User: def change_nick(self, nick: str): self.nick = nick - def change_color(self, color_name: Optional[str], deterministic=False): - color = xhtml.colors.get(color_name) + def change_color(self, color_name: Optional[str]): + color = xhtml.colors.get(color_name or '') if color is None: - log.error('Unknown color "%s"', color_name) - if deterministic: - self.set_deterministic_color() - else: - self.color = choice(get_theme().LIST_COLOR_NICKNAMES) + self.set_deterministic_color() else: self.color = (color, -1) @@ -93,7 +88,8 @@ class User: """ time: datetime object """ - self.last_talked = time + if time > self.last_talked: + self.last_talked = time def has_talked_since(self, t: int) -> bool: """ diff --git a/poezio/utils.py b/poezio/utils.py new file mode 100644 index 00000000..124d2002 --- /dev/null +++ b/poezio/utils.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" + Utilities +""" + +from random import choice + +VOWELS = 'aiueo' +CONSONANTS = 'bcdfghjklmnpqrstvwxz' + + +def pronounceable(length: int = 6) -> str: + """Generates a pronounceable name""" + out = '' + vowels = choice((True, False)) + for _ in range(0, length): + out += choice(VOWELS if vowels else CONSONANTS) + vowels = not vowels + return out diff --git a/poezio/version.py b/poezio/version.py new file mode 100644 index 00000000..2397b102 --- /dev/null +++ b/poezio/version.py @@ -0,0 +1,2 @@ +__version__ = '0.14' +__version_info__ = (0, 14, 0) diff --git a/poezio/windows/__init__.py b/poezio/windows/__init__.py index 8775b0a2..bbd6dc30 100644 --- a/poezio/windows/__init__.py +++ b/poezio/windows/__init__.py @@ -17,7 +17,7 @@ from poezio.windows.list import ListWin, ColumnHeaderWin from poezio.windows.misc import VerticalSeparator from poezio.windows.muc import UserList, Topic from poezio.windows.roster_win import RosterWin, ContactInfoWin -from poezio.windows.text_win import BaseTextWin, TextWin, XMLTextWin +from poezio.windows.text_win import TextWin from poezio.windows.image import ImageWin __all__ = [ @@ -28,5 +28,5 @@ __all__ = [ 'BookmarksInfoWin', 'ConfirmStatusWin', 'HelpText', 'Input', 'HistoryInput', 'MessageInput', 'CommandInput', 'ListWin', 'ColumnHeaderWin', 'VerticalSeparator', 'UserList', 'Topic', 'RosterWin', - 'ContactInfoWin', 'TextWin', 'XMLTextWin', 'ImageWin', 'BaseTextWin' + 'ContactInfoWin', 'TextWin', 'ImageWin' ] diff --git a/poezio/windows/base_wins.py b/poezio/windows/base_wins.py index 6dabd7b8..658e1533 100644 --- a/poezio/windows/base_wins.py +++ b/poezio/windows/base_wins.py @@ -7,40 +7,37 @@ the text window, the roster window, etc. A Tab (see the poezio.tabs module) is composed of multiple Windows """ -TAB_WIN = None - -import logging -log = logging.getLogger(__name__) +from __future__ import annotations import curses +import logging import string -from typing import Optional, Tuple +from contextlib import contextmanager +from typing import Optional, Tuple, TYPE_CHECKING, cast from poezio.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\x1A' +from poezio.ui.consts import FORMAT_CHAR +log = logging.getLogger(__name__) -class DummyWin: - def __getattribute__(self, name: str): - if name != '__bool__': - return lambda *args, **kwargs: (0, 0) - else: - return object.__getattribute__(self, name) - - def __bool__(self) -> bool: - return False +if TYPE_CHECKING: + from _curses import _CursesWindow # pylint: disable=E0611 class Win: __slots__ = ('_win', 'height', 'width', 'y', 'x') + width: int + height: int + x: int + y: int + def __init__(self) -> None: - self._win = None + if TAB_WIN is None: + raise ValueError + self._win: _CursesWindow = TAB_WIN self.height, self.width = 0, 0 def _resize(self, height: int, width: int, y: int, x: int) -> None: @@ -49,11 +46,11 @@ class Win: return self.height, self.width, self.x, self.y = height, width, x, y try: + if TAB_WIN is None: + raise ValueError('TAB_WIN is None') self._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: int, width: int, y: int, x: int) -> None: """ @@ -76,6 +73,24 @@ class Win: # of the screen. pass + @contextmanager + def colored_text(self, color: Optional[Tuple]=None, attr: Optional[int]=None): + """Context manager which sets up an attr/color when inside""" + if color is None and attr is None: + yield None + return + mode: int + if color is not None: + mode = to_curses_attr(color) + if attr is not None: + mode = mode | attr + else: + # attr cannot be none here + mode = cast(int, attr) + self._win.attron(mode) + yield None + self._win.attroff(mode) + def addstr(self, *args) -> None: """ Safe call to addstr @@ -160,3 +175,6 @@ class Win: self.addnstr(' ' * size, size, to_curses_attr(color)) else: self.addnstr(' ' * size, size) + + +TAB_WIN: Optional[_CursesWindow] = None diff --git a/poezio/windows/bookmark_forms.py b/poezio/windows/bookmark_forms.py index 2940ef04..a0e57cc7 100644 --- a/poezio/windows/bookmark_forms.py +++ b/poezio/windows/bookmark_forms.py @@ -4,22 +4,23 @@ Windows used inthe bookmarkstab import curses from typing import List, Tuple, Optional -from poezio.windows import base_wins +from slixmpp import JID, InvalidJID + from poezio.windows.base_wins import Win from poezio.windows.inputs import Input from poezio.windows.data_forms import FieldInput, FieldInputMixin from poezio.theming import to_curses_attr, get_theme -from poezio.common import safeJID from poezio.bookmarks import Bookmark, BookmarkList class BookmarkNameInput(FieldInput, Input): - def __init__(self, field) -> None: + def __init__(self, field: Bookmark) -> None: FieldInput.__init__(self, field) Input.__init__(self) self.text = field.name - self.pos = len(self.text) + self.pos = 0 + self.view_pos = 0 self.color = get_theme().COLOR_NORMAL_TEXT def save(self) -> None: @@ -30,17 +31,24 @@ class BookmarkNameInput(FieldInput, Input): class BookmarkJIDInput(FieldInput, Input): - def __init__(self, field) -> None: + def __init__(self, field: Bookmark) -> None: FieldInput.__init__(self, field) Input.__init__(self) - jid = safeJID(field.jid) + try: + jid = JID(field.jid) + except InvalidJID: + jid = JID('') jid.resource = field.nick or None self.text = jid.full - self.pos = len(self.text) + self.pos = 0 + self.view_pos = 0 self.color = get_theme().COLOR_NORMAL_TEXT def save(self) -> None: - jid = safeJID(self.get_text()) + try: + jid = JID(self.get_text()) + except InvalidJID: + jid = JID('') self._field.jid = jid.bare self._field.nick = jid.resource @@ -49,14 +57,14 @@ class BookmarkJIDInput(FieldInput, Input): class BookmarkMethodInput(FieldInputMixin): - def __init__(self, field) -> None: + def __init__(self, field: Bookmark) -> None: 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: str) -> None: + def do_command(self, key: str, raw: bool = False) -> None: if key == 'KEY_LEFT': if self.val_pos > 0: self.val_pos -= 1 @@ -89,7 +97,7 @@ class BookmarkMethodInput(FieldInputMixin): class BookmarkPasswordInput(FieldInput, Input): - def __init__(self, field) -> None: + def __init__(self, field: Bookmark) -> None: FieldInput.__init__(self, field) Input.__init__(self) self.text = field.password or '' @@ -119,13 +127,13 @@ class BookmarkPasswordInput(FieldInput, Input): class BookmarkAutojoinWin(FieldInputMixin): - def __init__(self, field) -> None: + def __init__(self, field: Bookmark) -> None: FieldInput.__init__(self, field) Win.__init__(self) self.last_key = 'KEY_RIGHT' self.value = field.autojoin - def do_command(self, key: str) -> None: + def do_command(self, key: str, raw: bool = False) -> None: if key == 'KEY_LEFT' or key == 'KEY_RIGHT': self.value = not self.value self.last_key = key @@ -155,14 +163,14 @@ class BookmarksWin(Win): __slots__ = ('scroll_pos', '_current_input', 'current_horizontal_input', '_bookmarks', 'lines') - def __init__(self, bookmarks: BookmarkList, height: int, width: int, y: int, x: int) -> None: - self._win = base_wins.TAB_WIN.derwin(height, width, y, x) + def __init__(self, bookmarks: BookmarkList) -> None: + Win.__init__(self) self.scroll_pos = 0 self._current_input = 0 self.current_horizontal_input = 0 self._bookmarks = list(bookmarks) - self.lines = [] # type: List[Tuple[BookmarkNameInput, BookmarkJIDInput, BookmarkPasswordInput, BookmarkAutojoinWin, BookmarkMethodInput]] - for bookmark in sorted(self._bookmarks, key=lambda x: x.jid): + self.lines: List[Tuple[BookmarkNameInput, BookmarkJIDInput, BookmarkPasswordInput, BookmarkAutojoinWin, BookmarkMethodInput]] = [] + for bookmark in sorted(self._bookmarks, key=lambda x: str(x.jid)): self.lines.append((BookmarkNameInput(bookmark), BookmarkJIDInput(bookmark), BookmarkPasswordInput(bookmark), @@ -190,11 +198,13 @@ class BookmarksWin(Win): BookmarkPasswordInput(bookmark), BookmarkAutojoinWin(bookmark), BookmarkMethodInput(bookmark))) - self.lines[self.current_input][ - self.current_horizontal_input].set_color( - get_theme().COLOR_NORMAL_TEXT) + if len(self.lines) > 1: + 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 len(self.lines) > 1: + 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() @@ -212,9 +222,7 @@ class BookmarksWin(Win): return bm def resize(self, height: int, width: int, y: int, x: int) -> None: - self.height = height - self.width = width - self._win = base_wins.TAB_WIN.derwin(height, width, y, x) + super().resize(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: @@ -245,9 +253,10 @@ class BookmarksWin(Win): return if self.current_input == 0: return + theme = get_theme() self.lines[self.current_input][ self.current_horizontal_input].set_color( - get_theme().COLOR_NORMAL_TEXT) + theme.COLOR_NORMAL_TEXT) self.current_input -= 1 # Adjust the scroll position if the current_input would be outside # of the visible area @@ -256,20 +265,21 @@ class BookmarksWin(Win): self.refresh() self.lines[self.current_input][ self.current_horizontal_input].set_color( - get_theme().COLOR_SELECTED_ROW) + theme.COLOR_SELECTED_ROW) def go_to_next_horizontal_input(self) -> None: if not self.lines: return + theme = get_theme() self.lines[self.current_input][ self.current_horizontal_input].set_color( - get_theme().COLOR_NORMAL_TEXT) + theme.COLOR_NORMAL_TEXT) self.current_horizontal_input += 1 - if self.current_horizontal_input > 3: + if self.current_horizontal_input > 4: self.current_horizontal_input = 0 self.lines[self.current_input][ self.current_horizontal_input].set_color( - get_theme().COLOR_SELECTED_ROW) + theme.COLOR_SELECTED_ROW) def go_to_next_page(self) -> bool: if not self.lines: @@ -278,9 +288,10 @@ class BookmarksWin(Win): if self.current_input == len(self.lines) - 1: return False + theme = get_theme() self.lines[self.current_input][ self.current_horizontal_input].set_color( - get_theme().COLOR_NORMAL_TEXT) + theme.COLOR_NORMAL_TEXT) inc = min(self.height, len(self.lines) - self.current_input - 1) if self.current_input + inc - self.scroll_pos > self.height - 1: @@ -291,7 +302,7 @@ class BookmarksWin(Win): self.current_input += inc self.lines[self.current_input][ self.current_horizontal_input].set_color( - get_theme().COLOR_SELECTED_ROW) + theme.COLOR_SELECTED_ROW) return True def go_to_previous_page(self) -> bool: @@ -301,9 +312,10 @@ class BookmarksWin(Win): if self.current_input == 0: return False + theme = get_theme() self.lines[self.current_input][ self.current_horizontal_input].set_color( - get_theme().COLOR_NORMAL_TEXT) + theme.COLOR_NORMAL_TEXT) dec = min(self.height, self.current_input) self.current_input -= dec @@ -314,7 +326,7 @@ class BookmarksWin(Win): self.refresh() self.lines[self.current_input][ self.current_horizontal_input].set_color( - get_theme().COLOR_SELECTED_ROW) + theme.COLOR_SELECTED_ROW) return True def go_to_previous_horizontal_input(self) -> None: @@ -322,19 +334,20 @@ class BookmarksWin(Win): return if self.current_horizontal_input == 0: return + theme = get_theme() self.lines[self.current_input][ self.current_horizontal_input].set_color( - get_theme().COLOR_NORMAL_TEXT) + 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) + theme.COLOR_SELECTED_ROW) - def on_input(self, key: str) -> None: + def on_input(self, key: str, raw: bool = False) -> None: if not self.lines: return self.lines[self.current_input][ - self.current_horizontal_input].do_command(key) + self.current_horizontal_input].do_command(key, raw=raw) def refresh(self) -> None: # store the cursor status @@ -356,7 +369,7 @@ class BookmarksWin(Win): continue if i >= self.height + self.scroll_pos: break - for j in range(4): + for j in range(5): inp[j].refresh() if self.lines and self.current_input < self.height - 1: @@ -377,5 +390,8 @@ class BookmarksWin(Win): def save(self) -> None: for line in self.lines: - for item in line: - item.save() + line[0].save() + line[1].save() + line[2].save() + line[3].save() + line[4].save() diff --git a/poezio/windows/data_forms.py b/poezio/windows/data_forms.py index b8dd8531..db174703 100644 --- a/poezio/windows/data_forms.py +++ b/poezio/windows/data_forms.py @@ -6,6 +6,7 @@ does not inherit from the Win base class), as it will create the others when needed. """ +from typing import Type from poezio.windows import base_wins from poezio.windows.base_wins import Win from poezio.windows.inputs import Input @@ -189,7 +190,7 @@ class TextMultiWin(FieldInputMixin): if not self.options or self.options[-1] != '': self.options.append('') else: - self.edition_input.do_command(key) + self.edition_input.do_command(key, raw=raw) self.refresh() def refresh(self): @@ -272,7 +273,7 @@ class ListMultiWin(FieldInputMixin): self._field.set_answer(values) def get_help_message(self): - return '←, →: Switch between the value. Space: select or unselect a value' + return '←, →: Switch between the value. Space: select or deselect a value' class ListSingleWin(FieldInputMixin): @@ -330,7 +331,8 @@ class TextSingleWin(FieldInputMixin, Input): Input.__init__(self) self.text = field.get_value() if isinstance(field.get_value(), str)\ else "" - self.pos = len(self.text) + self.pos = 0 + self.view_pos = 0 self.color = get_theme().COLOR_NORMAL_TEXT def reply(self): @@ -396,10 +398,10 @@ class FormWin: for (name, field) in self._form.getFields().items(): if field['type'] == 'hidden': continue - try: + if field['type'] not in self.input_classes: + input_class: Type[FieldInputMixin] = TextSingleWin + else: input_class = self.input_classes[field['type']] - except IndexError: - continue label = field['label'] desc = field['desc'] if field['type'] == 'fixed': @@ -438,10 +440,11 @@ class FormWin: return if self.current_input == len(self.inputs) - 1: return + theme = get_theme() self.inputs[self.current_input]['input'].set_color( - get_theme().COLOR_NORMAL_TEXT) + theme.COLOR_NORMAL_TEXT) self.inputs[self.current_input]['label'].set_color( - get_theme().COLOR_NORMAL_TEXT) + theme.COLOR_NORMAL_TEXT) self.current_input += 1 jump = 0 while self.current_input + jump != len( @@ -460,19 +463,20 @@ class FormWin: self.scroll_pos += 1 self.refresh() self.inputs[self.current_input]['input'].set_color( - get_theme().COLOR_SELECTED_ROW) + theme.COLOR_SELECTED_ROW) self.inputs[self.current_input]['label'].set_color( - get_theme().COLOR_SELECTED_ROW) + theme.COLOR_SELECTED_ROW) def go_to_previous_input(self): if not self.inputs: return if self.current_input == 0: return + theme = get_theme() self.inputs[self.current_input]['input'].set_color( - get_theme().COLOR_NORMAL_TEXT) + theme.COLOR_NORMAL_TEXT) self.inputs[self.current_input]['label'].set_color( - get_theme().COLOR_NORMAL_TEXT) + theme.COLOR_NORMAL_TEXT) self.current_input -= 1 jump = 0 while self.current_input - jump > 0 and self.inputs[self.current_input @@ -489,9 +493,9 @@ class FormWin: self.refresh() self.current_input -= jump self.inputs[self.current_input]['input'].set_color( - get_theme().COLOR_SELECTED_ROW) + theme.COLOR_SELECTED_ROW) self.inputs[self.current_input]['label'].set_color( - get_theme().COLOR_SELECTED_ROW) + theme.COLOR_SELECTED_ROW) def on_input(self, key, raw=False): if not self.inputs: @@ -521,11 +525,10 @@ class FormWin: 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) + color = get_theme().COLOR_SELECTED_ROW + self.inputs[self.current_input]['input'].set_color(color) 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'].set_color(color) self.inputs[self.current_input]['label'].refresh() def refresh_current_input(self): diff --git a/poezio/windows/image.py b/poezio/windows/image.py index 75f4d588..2862d2d9 100644 --- a/poezio/windows/image.py +++ b/poezio/windows/image.py @@ -2,6 +2,8 @@ Defines a window which contains either an image or a border. """ +from __future__ import annotations + import curses from io import BytesIO @@ -11,6 +13,15 @@ try: except ImportError: HAS_PIL = False +try: + import gi + gi.require_version('Rsvg', '2.0') + from gi.repository import Rsvg + import cairo + HAS_RSVG = True +except (ImportError, ValueError, AttributeError): + HAS_RSVG = False + from poezio.windows.base_wins import Win from poezio.theming import get_theme, to_curses_attr from poezio.xhtml import _parse_css_color @@ -19,6 +30,36 @@ from poezio.config import config from typing import Tuple, Optional, Callable +MAX_SIZE = 16 + + +def render_svg(svg: bytes) -> Optional[Image.Image]: + if not HAS_RSVG: + return None + try: + handle = Rsvg.Handle.new_from_data(svg) + dimensions = handle.get_dimensions() + biggest_dimension = max(dimensions.width, dimensions.height) + scale = MAX_SIZE / biggest_dimension + translate_x = (biggest_dimension - dimensions.width) / 2 + translate_y = (biggest_dimension - dimensions.height) / 2 + + surface = cairo.ImageSurface(cairo.Format.ARGB32, MAX_SIZE, MAX_SIZE) + context = cairo.Context(surface) + context.scale(scale, scale) + context.translate(translate_x, translate_y) + handle.render_cairo(context) + data = surface.get_data() + image = Image.frombytes('RGBA', (MAX_SIZE, MAX_SIZE), data.tobytes()) + # This is required because Cairo uses a BGRA (in host endianness) + # format, and PIL an ABGR (in byte order) format. Yes, this is + # confusing. + b, g, r, a = image.split() + return Image.merge('RGB', (r, g, b)) + except Exception: + return None + + class ImageWin(Win): """ A window which contains either an image or a border. @@ -27,10 +68,10 @@ class ImageWin(Win): __slots__ = ('_image', '_display_avatar') def __init__(self) -> None: - self._image = None # type: Optional[Image] + self._image: Optional[Image.Image] = None Win.__init__(self) - if config.get('image_use_half_blocks'): - self._display_avatar = self._display_avatar_half_blocks # type: Callable[[int, int], None] + if config.getbool('image_use_half_blocks'): + self._display_avatar: Callable[[int, int], None] = self._display_avatar_half_blocks else: self._display_avatar = self._display_avatar_full_blocks @@ -45,7 +86,14 @@ class ImageWin(Win): if data is not None and HAS_PIL: image_file = BytesIO(data) try: - image = Image.open(image_file) + try: + image = Image.open(image_file) + except OSError: + # TODO: Make the caller pass the MIME type, so we don’t + # have to try all renderers like that. + image = render_svg(data) + if image is None: + raise except OSError: self._display_border() else: diff --git a/poezio/windows/info_bar.py b/poezio/windows/info_bar.py index 15821c10..6e6c3bbd 100644 --- a/poezio/windows/info_bar.py +++ b/poezio/windows/info_bar.py @@ -5,14 +5,19 @@ This window is the one listing the current opened tabs in poezio. The GlobalInfoBar can be either horizontal or vertical (VerticalGlobalInfoBar). """ +import curses +import itertools import logging -log = logging.getLogger(__name__) -import curses +from typing import List, Optional from poezio.config import config from poezio.windows.base_wins import Win from poezio.theming import get_theme, to_curses_attr +from poezio.common import unique_prefix_of +from poezio.colors import ccg_text_to_color + +log = logging.getLogger(__name__) class GlobalInfoBar(Win): @@ -25,42 +30,93 @@ class GlobalInfoBar(Win): def refresh(self) -> None: log.debug('Refresh: %s', self.__class__.__name__) self._win.erase() + theme = get_theme() self.addstr(0, 0, "[", - to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) + to_curses_attr(theme.COLOR_INFORMATION_BAR)) - 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') + show_names = config.getbool('show_tab_names') + show_nums = config.getbool('show_tab_numbers') + use_nicks = config.getbool('use_tab_nicks') + show_inactive = config.getbool('show_inactive_tabs') + unique_prefix_tab_names = config.getbool('unique_prefix_tab_names') + autocolor_tab_names = config.getbool('autocolor_tab_names') + + if unique_prefix_tab_names: + unique_prefixes: List[Optional[str]] = [None] * len(self.core.tabs) + sorted_tab_indices = sorted( + (str(tab.name), i) + for i, tab in enumerate(self.core.tabs) + ) + prev_name = "" + for (name, i), next_item in itertools.zip_longest( + sorted_tab_indices, sorted_tab_indices[1:]): + # TODO: should this maybe use something smarter than .lower()? + # something something stringprep? + name = name.lower() + prefix_prev = unique_prefix_of(name, prev_name) + if next_item is not None: + prefix_next = unique_prefix_of(name, next_item[0].lower()) + else: + prefix_next = name[0] + + # to be unique, we have to use the longest prefix + if len(prefix_next) > len(prefix_prev): + prefix = prefix_next + else: + prefix = prefix_prev + + unique_prefixes[i] = prefix + prev_name = name for nb, tab in enumerate(self.core.tabs): if not tab: continue color = tab.color - if not show_inactive and color is get_theme().COLOR_TAB_NORMAL: + if not show_inactive and color is theme.COLOR_TAB_NORMAL and ( + tab.priority < 0): continue + if autocolor_tab_names: + # TODO: in case of private MUC conversations, we should try to + # get hold of more information to make the colour the same as + # the nickname colour in the MUC. + fgcolor, bgcolor, *flags = color + # this is fugly, but I’m not sure how to improve it... since + # apparently the state is only kept in the color -.- + if (color == theme.COLOR_TAB_HIGHLIGHT or + color == theme.COLOR_TAB_PRIVATE): + fgcolor = ccg_text_to_color(theme.ccg_palette, tab.name) + bgcolor = -1 + flags = theme.MODE_TAB_IMPORTANT + elif color == theme.COLOR_TAB_NEW_MESSAGE: + fgcolor = ccg_text_to_color(theme.ccg_palette, tab.name) + bgcolor = -1 + flags = theme.MODE_TAB_NORMAL + + color = (fgcolor, bgcolor) + tuple(flags) 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: + if unique_prefix_tab_names: + self.addstr(unique_prefixes[nb], to_curses_attr(color)) + elif 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)) + to_curses_attr(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)) + to_curses_attr(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)) + to_curses_attr(theme.COLOR_INFORMATION_BAR)) self._refresh() @@ -76,17 +132,24 @@ class VerticalGlobalInfoBar(Win): 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'): + theme = get_theme() + if not config.getbool('show_inactive_tabs'): sorted_tabs = [ tab for tab in sorted_tabs - if tab.vertical_color != get_theme().COLOR_VERTICAL_TAB_NORMAL + if ( + tab.vertical_color != theme.COLOR_VERTICAL_TAB_NORMAL or + tab.priority > 0 + ) ] nb_tabs = len(sorted_tabs) - use_nicks = config.get('use_tab_nicks') + use_nicks = config.getbool('use_tab_nicks') if nb_tabs >= height: + # TODO: As sorted_tabs filters out gap tabs this ensures pos is + # always set, preventing UnboundLocalError. Now is this how this + # should be fixed. + pos = 0 for y, tab in enumerate(sorted_tabs): - if tab.vertical_color == get_theme( - ).COLOR_VERTICAL_TAB_CURRENT: + if tab.vertical_color == theme.COLOR_VERTICAL_TAB_CURRENT: pos = y break # center the current tab as much as possible @@ -96,20 +159,20 @@ class VerticalGlobalInfoBar(Win): 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') + asc_sort = (config.getstr('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)) + to_curses_attr(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) + separator = to_curses_attr(theme.COLOR_VERTICAL_SEPARATOR) self._win.attron(separator) self._win.vline(0, width - 1, curses.ACS_VLINE, height) self._win.attroff(separator) diff --git a/poezio/windows/info_wins.py b/poezio/windows/info_wins.py index abc0a401..227dc115 100644 --- a/poezio/windows/info_wins.py +++ b/poezio/windows/info_wins.py @@ -3,15 +3,27 @@ Module defining all the "info wins", ie the bar which is on top of the info buffer in normal tabs """ +from __future__ import annotations + +from typing import Optional, Dict, TYPE_CHECKING, Any + import logging -log = logging.getLogger(__name__) -from poezio.common import safeJID +from slixmpp import JID, InvalidJID + from poezio.config import config from poezio.windows.base_wins import Win -from poezio.windows.funcs import truncate_nick +from poezio.ui.funcs import truncate_nick from poezio.theming import get_theme, to_curses_attr +from poezio.colors import ccg_text_to_color + +if TYPE_CHECKING: + from poezio.user import User + from poezio.tabs import MucTab + from poezio.windows import TextWin + +log = logging.getLogger(__name__) class InfoWin(Win): @@ -92,11 +104,18 @@ class PrivateInfoWin(InfoWin): to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) def write_room_name(self, name): - jid = safeJID(name) + # TODO: autocolour this too, but we need more info about the occupant + # (whether we know its real jid) and the room (whether it is + # anonymous) to provide correct colouring. + try: + jid = JID(name) + except InvalidJID: + jid = JID('') room_name, nick = jid.bare, jid.resource - self.addstr(nick, to_curses_attr(get_theme().COLOR_PRIVATE_NAME)) + theme = get_theme() + self.addstr(nick, to_curses_attr(theme.COLOR_PRIVATE_NAME)) txt = ' from room %s' % room_name - self.addstr(txt, to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) + self.addstr(txt, to_curses_attr(theme.COLOR_INFORMATION_BAR)) def write_chatstate(self, state): if state: @@ -119,15 +138,16 @@ class MucListInfoWin(InfoWin): def refresh(self, name=None, window=None): log.debug('Refresh: %s', self.__class__.__name__) self._win.erase() + theme = get_theme() if name: self.addstr(name, - to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) + to_curses_attr(theme.COLOR_INFORMATION_BAR)) else: self.addstr(self.message, - to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) + to_curses_attr(theme.COLOR_INFORMATION_BAR)) if window: self.print_scroll_position(window) - self.finish_line(get_theme().COLOR_INFORMATION_BAR) + self.finish_line(theme.COLOR_INFORMATION_BAR) self._refresh() @@ -147,7 +167,10 @@ class ConversationInfoWin(InfoWin): # 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) + try: + jid = JID(jid) + except InvalidJID: + jid = JID('') if contact: if jid.resource: resource = contact[jid.full] @@ -161,7 +184,7 @@ class ConversationInfoWin(InfoWin): # 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'): + if config.getbool('show_jid_in_conversations'): self.write_contact_jid(jid) self.write_contact_information(contact) self.write_resource_information(resource) @@ -176,9 +199,9 @@ class ConversationInfoWin(InfoWin): Write all information added by plugins by getting the value returned by the callbacks. """ + color = to_curses_attr(get_theme().COLOR_INFORMATION_BAR) for plugin in information.values(): - self.addstr(plugin(jid), - to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) + self.addstr(plugin(jid), color) def write_resource_information(self, resource): """ @@ -188,38 +211,58 @@ class ConversationInfoWin(InfoWin): presence = "unavailable" else: presence = resource.presence - color = get_theme().color_show(presence) + theme = get_theme() + color = theme.color_show(presence) if not presence: - presence = get_theme().CHAR_STATUS - self.addstr('[', to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) + presence = theme.CHAR_STATUS + self.addstr('[', to_curses_attr(theme.COLOR_INFORMATION_BAR)) self.addstr(presence, to_curses_attr(color)) if resource and resource.status: shortened = resource.status[:20] + (resource.status[:20] and '…') self.addstr(' %s' % shortened, - to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) - self.addstr(']', to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) + to_curses_attr(theme.COLOR_INFORMATION_BAR)) + self.addstr(']', to_curses_attr(theme.COLOR_INFORMATION_BAR)) def write_contact_information(self, contact): """ Write the information about the contact """ + theme = get_theme() + color = to_curses_attr(theme.COLOR_INFORMATION_BAR) + if config.get('autocolor_tab_names') and contact is not None: + name_color = ( + ccg_text_to_color(theme.ccg_palette, str(contact.bare_jid)), + -1, + theme.MODE_TAB_NAME, + ) + else: + name_color = color + if not contact: - self.addstr("(contact not in roster)", - to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) + self.addstr("(contact not in roster)", color) return display_name = contact.name if display_name: - self.addstr('%s ' % (display_name), - to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) + self.addstr('%s ' % (display_name), name_color) 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)) + theme = get_theme() + color = to_curses_attr(theme.COLOR_INFORMATION_BAR) + if config.get('autocolor_tab_names'): + name_color = ( + ccg_text_to_color(theme.ccg_palette, str(contact.jid)), + -1, + theme.MODE_TAB_NAME, + ) + else: + name_color = theme.COLOR_CONVERSATION_NAME + + self.addstr('[', color) + self.addstr(jid.full, to_curses_attr(name_color)) + self.addstr('] ', color) def write_chatstate(self, state): if state: @@ -236,14 +279,16 @@ class DynamicConversationInfoWin(ConversationInfoWin): """ log.debug("write_contact_jid DynamicConversationInfoWin, jid: %s", jid.resource) - self.addstr('[', to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) + theme = get_theme() + color = to_curses_attr(theme.COLOR_INFORMATION_BAR) + self.addstr('[', color) self.addstr(jid.bare, - to_curses_attr(get_theme().COLOR_CONVERSATION_NAME)) + to_curses_attr(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)) + to_curses_attr(theme.COLOR_CONVERSATION_RESOURCE)) + self.addstr('] ', color) class MucInfoWin(InfoWin): @@ -254,10 +299,16 @@ class MucInfoWin(InfoWin): __slots__ = () - def __init__(self): + def __init__(self) -> None: InfoWin.__init__(self) - def refresh(self, room, window=None, user=None, information=None): + def refresh( + self, + room: MucTab, + window: Optional[TextWin] = None, + user: Optional[User] = None, + information: Optional[Dict[str, Any]] = None + ) -> None: log.debug('Refresh: %s', self.__class__.__name__) self._win.erase() self.write_room_name(room) @@ -277,22 +328,34 @@ class MucInfoWin(InfoWin): Write all information added by plugins by getting the value returned by the callbacks. """ + color = to_curses_attr(get_theme().COLOR_INFORMATION_BAR) for plugin in information.values(): - self.addstr(plugin(jid), - to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) + self.addstr(plugin(jid), color) def write_room_name(self, room): - self.addstr('[', to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) + theme = get_theme() + color = to_curses_attr(theme.COLOR_INFORMATION_BAR) + label_color = theme.COLOR_GROUPCHAT_NAME + + if config.get('autocolor_tab_names'): + label_color = ccg_text_to_color( + theme.ccg_palette, + room.jid.bare, + ), -1, theme.MODE_TAB_NAME + + self.addstr('[', color) self.addstr(room.name, - to_curses_attr(get_theme().COLOR_GROUPCHAT_NAME)) - self.addstr(']', to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) + to_curses_attr(label_color)) + self.addstr(']', color) def write_participants_number(self, room): - self.addstr('{', to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) + theme = get_theme() + color = to_curses_attr(theme.COLOR_INFORMATION_BAR) + self.addstr('{', color) self.addstr( str(len(room.users)), - to_curses_attr(get_theme().COLOR_GROUPCHAT_NAME)) - self.addstr('} ', to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) + to_curses_attr(theme.COLOR_GROUPCHAT_NAME)) + self.addstr('} ', color) def write_disconnected(self, room): """ @@ -338,7 +401,10 @@ class ConversationStatusMessageWin(InfoWin): def refresh(self, jid, contact): log.debug('Refresh: %s', self.__class__.__name__) - jid = safeJID(jid) + try: + jid = JID(jid) + except InvalidJID: + jid = JID('') if contact: if jid.resource: resource = contact[jid.full] @@ -386,10 +452,11 @@ class ConfirmStatusWin(Win): def refresh(self): log.debug('Refresh: %s', self.__class__.__name__) self._win.erase() + theme = get_theme() if self.critical: - color = get_theme().COLOR_WARNING_PROMPT + color = theme.COLOR_WARNING_PROMPT else: - color = get_theme().COLOR_INFORMATION_BAR + color = theme.COLOR_INFORMATION_BAR c_color = to_curses_attr(color) self.addstr(self.text, c_color) self.finish_line(color) diff --git a/poezio/windows/input_placeholders.py b/poezio/windows/input_placeholders.py index 4d414636..3ec57583 100644 --- a/poezio/windows/input_placeholders.py +++ b/poezio/windows/input_placeholders.py @@ -23,7 +23,7 @@ class HelpText(Win): def __init__(self, text: str = '') -> None: Win.__init__(self) - self.txt = text # type: str + self.txt: str = text def refresh(self, txt: Optional[str] = None) -> None: log.debug('Refresh: %s', self.__class__.__name__) diff --git a/poezio/windows/inputs.py b/poezio/windows/inputs.py index c0c73419..01b94ac0 100644 --- a/poezio/windows/inputs.py +++ b/poezio/windows/inputs.py @@ -5,13 +5,14 @@ Text inputs. import curses import logging import string -from typing import List, Dict, Callable, Optional +from typing import List, Dict, Callable, Optional, ClassVar from poezio import keyboard from poezio import common from poezio import poopt -from poezio.windows.base_wins import Win, format_chars -from poezio.windows.funcs import find_first_format_char +from poezio.windows.base_wins import Win +from poezio.ui.consts import FORMAT_CHARS +from poezio.ui.funcs import find_first_format_char from poezio.config import config from poezio.theming import to_curses_attr @@ -40,7 +41,7 @@ class Input(Win): # it easy cut and paste text between various input def __init__(self) -> None: - self.key_func = { + self.key_func: Dict[str, Callable] = { "KEY_LEFT": self.key_left, "KEY_RIGHT": self.key_right, "KEY_END": self.key_end, @@ -65,7 +66,7 @@ class Input(Win): '^?': self.key_backspace, "M-^?": self.delete_word, # '^J': self.add_line_break, - } # type: Dict[str, Callable] + } Win.__init__(self) self.text = '' self.pos = 0 # The position of the “cursor” in the text @@ -75,8 +76,8 @@ class Input(Win): # screen self.on_input = DEFAULT_ON_INPUT # callback called on any key pressed self.color = None # use this color on addstr - self.last_completion = None # type: Optional[str] - self.hit_list = [] # type: List[str] + self.last_completion: Optional[str] = None + self.hit_list: List[str] = [] def on_delete(self) -> None: """ @@ -109,7 +110,7 @@ class Input(Win): """ if self.pos == 0: return True - separators = string.punctuation + ' ' + separators = string.punctuation + ' ' + '\n' 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: @@ -122,7 +123,7 @@ class Input(Win): """ if self.is_cursor_at_end(): return True - separators = string.punctuation + ' ' + separators = string.punctuation + ' ' + '\n' 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. @@ -134,7 +135,7 @@ class Input(Win): """ Delete the word just before the cursor """ - separators = string.punctuation + ' ' + separators = string.punctuation + ' ' + '\n' 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: @@ -145,7 +146,7 @@ class Input(Win): """ Delete the word just after the cursor """ - separators = string.punctuation + ' ' + separators = string.punctuation + ' ' + '\n' 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. @@ -408,12 +409,14 @@ class Input(Win): Normal completion """ pos = self.pos - if pos < len( - self.text) and after.endswith(' ') and self.text[pos] == ' ': + if pos < len(self.text) and after.endswith(' ') and self.text[pos] in ' \n': 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) + line_before_cursor = self.text.rfind('\n', 0, pos) + if line_before_cursor > space_before_cursor: + space_before_cursor = line_before_cursor if space_before_cursor != -1: begin = self.text[space_before_cursor + 1:pos] else: @@ -487,7 +490,7 @@ class Input(Win): (\x0E to \x19 instead of \x19 + attr). We do not use any } char in this version """ - chars = format_chars + '\n' + chars = FORMAT_CHARS + '\n' if y is not None and x is not None: self.move(y, x) format_char = find_first_format_char(text, chars) @@ -497,7 +500,7 @@ class Input(Win): if text[format_char] == '\n': attr_char = '|' else: - attr_char = self.text_attributes[format_chars.index( + attr_char = self.text_attributes[FORMAT_CHARS.index( text[format_char])] self.addstr(text[:format_char]) self.addstr(attr_char, curses.A_REVERSE) @@ -589,9 +592,10 @@ class HistoryInput(Input): An input with colors and stuff, plus an history ^R allows to search inside the history (as in a shell) """ - __slots__ = ('help_message', 'histo_pos', 'current_completed', 'search') + __slots__ = ('help_message', 'histo_pos', 'current_completed', 'search', + 'history') - history = [] # type: List[str] + global_history: ClassVar[List[str]] = [] def __init__(self) -> None: Input.__init__(self) @@ -600,8 +604,10 @@ class HistoryInput(Input): self.current_completed = '' self.key_func['^R'] = self.toggle_search self.search = False - if config.get('separate_history'): - self.history = [] # type: List[str] + if config.getbool('separate_history'): + self.history: List[str] = [] + else: + self.history = self.__class__.global_history def toggle_search(self) -> None: if self.help_message: @@ -678,7 +684,7 @@ class MessageInput(HistoryInput): Also letting the user enter colors or other text markups """ # The history is common to all MessageInput - history = [] # type: List[str] + global_history: ClassVar[List[str]] = [] def __init__(self) -> None: HistoryInput.__init__(self) @@ -695,7 +701,7 @@ class MessageInput(HistoryInput): def cb(attr_char): if attr_char in self.text_attributes: - char = format_chars[self.text_attributes.index(attr_char)] + char = FORMAT_CHARS[self.text_attributes.index(attr_char)] self.do_command(char, False) self.rewrite_text() @@ -724,7 +730,7 @@ class CommandInput(HistoryInput): HelpMessage when a command is started The on_input callback """ - history = [] # type: List[str] + global_history: ClassVar[List[str]] = [] def __init__(self, help_message: str, on_abort, on_success, on_input=None) -> None: HistoryInput.__init__(self) diff --git a/poezio/windows/list.py b/poezio/windows/list.py index f03dcf6a..1c5d834f 100644 --- a/poezio/windows/list.py +++ b/poezio/windows/list.py @@ -24,10 +24,10 @@ class ListWin(Win): def __init__(self, columns: Dict[str, int], with_headers: bool = True) -> None: Win.__init__(self) - self._columns = columns # type: Dict[str, int] - self._columns_sizes = {} # type: Dict[str, int] + self._columns: Dict[str, int] = columns + self._columns_sizes: Dict[str, int] = {} self.sorted_by = (None, None) # for example: ('name', '↑') - self.lines = [] # type: List[str] + self.lines: List[str] = [] self._selected_row = 0 self._starting_pos = 0 # The column number from which we start the refresh @@ -40,7 +40,7 @@ class ListWin(Win): def empty(self) -> None: """ - emtpy the list and reset some important values as well + empty the list and reset some important values as well """ self.lines = [] self._selected_row = 0 @@ -94,6 +94,7 @@ class ListWin(Win): log.debug('Refresh: %s', self.__class__.__name__) self._win.erase() lines = self.lines[self._starting_pos:self._starting_pos + self.height] + color = to_curses_attr(get_theme().COLOR_INFORMATION_BAR) for y, line in enumerate(lines): x = 0 for col in self._columns.items(): @@ -106,9 +107,7 @@ class ListWin(Win): 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)) + self.addstr(y, x, txt[:size], color) else: self.addstr(y, x, txt[:size]) x += size @@ -174,7 +173,7 @@ class ColumnHeaderWin(Win): def __init__(self, columns: List[str]) -> None: Win.__init__(self) self._columns = columns - self._columns_sizes = {} # type: Dict[str, int] + self._columns_sizes: Dict[str, int] = {} self._column_sel = '' self._column_order = '' self._column_order_asc = False @@ -189,23 +188,24 @@ class ColumnHeaderWin(Win): log.debug('Refresh: %s', self.__class__.__name__) self._win.erase() x = 0 + theme = get_theme() for col in self._columns: txt = col if col in self._column_order: if self._column_order_asc: - txt += get_theme().CHAR_COLUMN_ASC + txt += theme.CHAR_COLUMN_ASC else: - txt += get_theme().CHAR_COLUMN_DESC + txt += 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)) + to_curses_attr(theme.COLOR_COLUMN_HEADER_SEL)) else: self.addstr(0, x, txt, - to_curses_attr(get_theme().COLOR_COLUMN_HEADER)) + to_curses_attr(theme.COLOR_COLUMN_HEADER)) x += size self._refresh() diff --git a/poezio/windows/misc.py b/poezio/windows/misc.py index 6c04b814..a621b61d 100644 --- a/poezio/windows/misc.py +++ b/poezio/windows/misc.py @@ -22,8 +22,10 @@ class VerticalSeparator(Win): __slots__ = () def rewrite_line(self) -> None: - self._win.vline(0, 0, curses.ACS_VLINE, self.height, - to_curses_attr(get_theme().COLOR_VERTICAL_SEPARATOR)) + self._win.vline( + 0, 0, curses.ACS_VLINE, self.height, + to_curses_attr(get_theme().COLOR_VERTICAL_SEPARATOR) + ) # type: ignore self._refresh() def refresh(self) -> None: @@ -37,7 +39,7 @@ class SimpleTextWin(Win): def __init__(self, text) -> None: Win.__init__(self) self._text = text - self.built_lines = [] # type: List[str] + self.built_lines: List[str] = [] def rebuild_text(self) -> None: """ diff --git a/poezio/windows/muc.py b/poezio/windows/muc.py index 72dc602c..0e95ac1b 100644 --- a/poezio/windows/muc.py +++ b/poezio/windows/muc.py @@ -33,7 +33,7 @@ class UserList(Win): def __init__(self) -> None: Win.__init__(self) self.pos = 0 - self.cache = [] # type: List[CachedUser] + self.cache: List[CachedUser] = [] def scroll_up(self) -> bool: self.pos += self.height - 1 @@ -65,14 +65,14 @@ class UserList(Win): def refresh(self, users: List[User]) -> None: log.debug('Refresh: %s', self.__class__.__name__) - if config.get('hide_user_list'): + if config.getbool('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') + asc_sort = (config.getstr('user_list_sort').lower() == 'asc') if asc_sort: y, _ = self._win.getmaxyx() y -= 1 @@ -110,15 +110,16 @@ class UserList(Win): self.addstr(y, 1, symbol, to_curses_attr(color)) def draw_status_chatstate(self, y: int, user: User) -> None: - show_col = get_theme().color_show(user.show) + theme = get_theme() + show_col = theme.color_show(user.show) if user.chatstate == 'composing': - char = get_theme().CHAR_CHATSTATE_COMPOSING + char = theme.CHAR_CHATSTATE_COMPOSING elif user.chatstate == 'active': - char = get_theme().CHAR_CHATSTATE_ACTIVE + char = theme.CHAR_CHATSTATE_ACTIVE elif user.chatstate == 'paused': - char = get_theme().CHAR_CHATSTATE_PAUSED + char = theme.CHAR_CHATSTATE_PAUSED else: - char = get_theme().CHAR_STATUS + char = theme.CHAR_STATUS self.addstr(y, 0, char, to_curses_attr(show_col)) def resize(self, height: int, width: int, y: int, x: int) -> None: @@ -138,17 +139,18 @@ class Topic(Win): def refresh(self, topic: Optional[str] = None) -> None: log.debug('Refresh: %s', self.__class__.__name__) + theme = get_theme() self._win.erase() if topic is not None: 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)) + self.addstr(0, 0, msg, to_curses_attr(theme.COLOR_TOPIC_BAR)) _, 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)) + to_curses_attr(theme.COLOR_INFORMATION_BAR)) self._refresh() def set_message(self, message) -> None: diff --git a/poezio/windows/roster_win.py b/poezio/windows/roster_win.py index 3c62ea0a..dfdc9b9b 100644 --- a/poezio/windows/roster_win.py +++ b/poezio/windows/roster_win.py @@ -6,11 +6,10 @@ import logging log = logging.getLogger(__name__) from datetime import datetime -from typing import Optional, List, Union, Dict +from typing import Optional, List, Union from poezio.windows.base_wins import Win -from poezio import common from poezio.config import config from poezio.contact import Contact, Resource from poezio.roster import Roster, RosterGroup @@ -26,8 +25,8 @@ class RosterWin(Win): 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 # type: Optional[Row] - self.roster_cache = [] # type: List[Row] + self.selected_row: Optional[Row] = None + self.roster_cache: List[Row] = [] @property def roster_len(self) -> int: @@ -99,13 +98,13 @@ class RosterWin(Win): # This is a search if roster.contact_filter is not roster.DEFAULT_FILTER: self.roster_cache = [] - sort = config.get('roster_sort', 'jid:show') or 'jid:show' + sort = config.getstr('roster_sort') 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') - sort = config.get('roster_sort') or 'jid:show' - group_sort = config.get('roster_group_sort') or 'name' + show_offline = config.getbool('roster_show_offline') + sort = config.getstr('roster_sort') or 'jid:show' + group_sort = config.getstr('roster_group_sort') or 'name' self.roster_cache = [] # build the cache for group in roster.get_groups(group_sort): @@ -155,9 +154,9 @@ class RosterWin(Win): 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') + 'show_roster_sub': config.getbool('show_roster_subscriptions'), + 'show_s2s_errors': config.getbool('show_s2s_errors'), + 'show_roster_jids': config.getbool('show_roster_jids') } for item in roster_view: @@ -171,7 +170,7 @@ class RosterWin(Win): group = item.name elif isinstance(item, Contact): self.draw_contact_line(y, item, draw_selected, group, - **options) + **options) # type: ignore elif isinstance(item, Resource): self.draw_resource_line(y, item, draw_selected) @@ -195,18 +194,20 @@ class RosterWin(Win): """ The header at the top """ + color = get_theme().COLOR_INFORMATION_BAR 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) + to_curses_attr(color)) + self.finish_line(color) def draw_group(self, y: int, group: RosterGroup, colored: bool) -> None: """ Draw a groupname on a line """ + color = to_curses_attr(get_theme().COLOR_SELECTED_ROW) if colored: - self._win.attron(to_curses_attr(get_theme().COLOR_SELECTED_ROW)) + self._win.attron(color) if group.folded: self.addstr(y, 0, '[+] ') else: @@ -217,7 +218,7 @@ class RosterWin(Win): self.truncate_name(group.name, len(contacts) + 4) + contacts) if colored: - self._win.attroff(to_curses_attr(get_theme().COLOR_SELECTED_ROW)) + self._win.attroff(color) self.finish_line() def truncate_name(self, name, added): @@ -263,17 +264,9 @@ class RosterWin(Win): added += 4 if contact.ask: - added += len(get_theme().CHAR_ROSTER_ASKED) + added += len(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) + added += len(theme.CHAR_ROSTER_ERROR) if show_roster_sub in ('all', 'incomplete', 'to', 'from', 'both', 'none'): added += len( @@ -285,13 +278,13 @@ class RosterWin(Win): elif contact.name and contact.name != contact.bare_jid: display_name = '%s (%s)' % (contact.name, contact.bare_jid) else: - display_name = contact.bare_jid + display_name = str(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)) + to_curses_attr(theme.COLOR_SELECTED_ROW)) else: self.addstr(display_name) @@ -302,34 +295,23 @@ class RosterWin(Win): 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)) + self.addstr(theme.CHAR_ROSTER_ASKED, + to_curses_attr(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.addstr(theme.CHAR_ROSTER_ERROR, + to_curses_attr(theme.COLOR_ROSTER_ERROR)) self.finish_line() def draw_resource_line(self, y: int, resource: Resource, colored: bool) -> None: """ Draw a specific resource line """ - color = get_theme().color_show(resource.presence) - self.addstr(y, 4, get_theme().CHAR_STATUS, to_curses_attr(color)) + theme = get_theme() + color = theme.color_show(resource.presence) + self.addstr(y, 4, theme.CHAR_STATUS, to_curses_attr(color)) if colored: self.addstr(y, 8, self.truncate_name(str(resource.jid), 6), - to_curses_attr(get_theme().COLOR_SELECTED_ROW)) + to_curses_attr(theme.COLOR_SELECTED_ROW)) else: self.addstr(y, 8, self.truncate_name(str(resource.jid), 6)) self.finish_line() @@ -350,6 +332,7 @@ class ContactInfoWin(Win): """ draw the contact information """ + theme = get_theme() resource = contact.get_highest_priority_resource() if contact: jid = str(contact.bare_jid) @@ -365,8 +348,8 @@ class ContactInfoWin(Win): self.addstr(0, 0, '%s (%s)' % ( jid, presence, - ), to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) - self.finish_line(get_theme().COLOR_INFORMATION_BAR) + ), to_curses_attr(theme.COLOR_INFORMATION_BAR)) + self.finish_line(theme.COLOR_INFORMATION_BAR) i += 1 self.addstr(i, 0, 'Subscription: %s' % (contact.subscription, )) self.finish_line() @@ -374,7 +357,7 @@ class ContactInfoWin(Win): if contact.ask: if contact.ask == 'asked': self.addstr(i, 0, 'Ask: %s' % (contact.ask, ), - to_curses_attr(get_theme().COLOR_IMPORTANT_TEXT)) + to_curses_attr(theme.COLOR_IMPORTANT_TEXT)) else: self.addstr(i, 0, 'Ask: %s' % (contact.ask, )) self.finish_line() @@ -386,33 +369,7 @@ class ContactInfoWin(Win): 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)) + to_curses_attr(theme.COLOR_ROSTER_ERROR)) self.finish_line() i += 1 @@ -420,9 +377,10 @@ class ContactInfoWin(Win): """ draw the group information """ + theme = get_theme() self.addstr(0, 0, group.name, - to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) - self.finish_line(get_theme().COLOR_INFORMATION_BAR) + to_curses_attr(theme.COLOR_INFORMATION_BAR)) + self.finish_line(theme.COLOR_INFORMATION_BAR) def refresh(self, selected_row: Row) -> None: log.debug('Refresh: %s', self.__class__.__name__) diff --git a/poezio/windows/text_win.py b/poezio/windows/text_win.py index d0669b26..12d90e7d 100644 --- a/poezio/windows/text_win.py +++ b/poezio/windows/text_win.py @@ -4,50 +4,50 @@ Can be locked, scrolled, has a separator, etc… """ import logging -import curses -from math import ceil, log10 from typing import Optional, List, Union -from poezio.windows.base_wins import Win, FORMAT_CHAR -from poezio.windows.funcs import truncate_nick, parse_attrs +from poezio.windows.base_wins import Win +from poezio.text_buffer import TextBuffer -from poezio import poopt from poezio.config import config -from poezio.theming import to_curses_attr, get_theme, dump_tuple -from poezio.text_buffer import Message +from poezio.theming import to_curses_attr, get_theme +from poezio.ui.types import Message, BaseMessage +from poezio.ui.render import Line, build_lines, write_pre log = logging.getLogger(__name__) -# msg is a reference to the corresponding Message object. text_start and -# text_end are the position delimiting the text in this line. -class Line: - __slots__ = ('msg', 'start_pos', 'end_pos', 'prepend') - - def __init__(self, msg: Message, start_pos: int, end_pos: int, prepend: str) -> None: - self.msg = msg - self.start_pos = start_pos - self.end_pos = end_pos - self.prepend = prepend - - -class BaseTextWin(Win): +class TextWin(Win): __slots__ = ('lines_nb_limit', 'pos', 'built_lines', 'lock', 'lock_buffer', - 'separator_after') + 'separator_after', 'highlights', 'hl_pos', + 'nb_of_highlights_after_separator') + + hl_pos: Optional[int] def __init__(self, lines_nb_limit: Optional[int] = None) -> 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 # type: int + if lines_nb_limit is None: + lines_nb_limit = config.getint('max_lines_in_memory') + self.lines_nb_limit: int = lines_nb_limit self.pos = 0 # Each new message is built and kept here. # on resize, we rebuild all the messages - self.built_lines = [] # type: List[Union[None, Line]] + self.built_lines: List[Union[None, Line]] = [] self.lock = False - self.lock_buffer = [] # type: List[Union[None, Line]] - self.separator_after = None # type: Optional[Line] + self.lock_buffer: List[Union[None, Line]] = [] + self.separator_after: Optional[BaseMessage] = None + # the Lines of the highlights in that buffer + self.highlights: List[Line] = [] + # 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 = None + + # 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 def toggle_lock(self) -> bool: if self.lock: @@ -80,12 +80,9 @@ class BaseTextWin(Win): self.pos = 0 return self.pos != pos - # TODO: figure out the type of history. def build_new_message(self, - message: Message, - history=None, + message: BaseMessage, clean: bool = True, - highlight: bool = False, timestamp: bool = False, nick_size: int = 10) -> int: """ @@ -93,29 +90,55 @@ class BaseTextWin(Win): Return the number of lines that are built for the given message. """ - #pylint: disable=assignment-from-no-return - lines = self.build_message( - message, timestamp=timestamp, nick_size=nick_size) + lines = build_lines( + message, self.width, 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 isinstance(message, Message) and message.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: Message, timestamp: bool = False, nick_size: int = 10) -> List[Union[None, Line]]: - """ - Build a list of lines from a message, without adding it - to a list - """ - return [] - def refresh(self) -> None: - pass + 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.getbool("show_timestamps") + nick_size = config.getint("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 = write_pre(msg, self, with_timestamps, nick_size) + elif y == 0: + offset = msg.compute_offset(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 write_text(self, y: int, x: int, txt: str) -> None: """ @@ -123,28 +146,15 @@ class BaseTextWin(Win): """ self.addstr_colored(txt, y, x) - def write_time(self, time: str) -> int: - """ - 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 - - # TODO: figure out the type of room. - def resize(self, height: int, width: int, y: int, x: int, room=None) -> None: + def resize(self, height: int, width: int, y: int, x: int, + room: Optional[TextBuffer] = None, force: bool = False) -> None: + old_width: Optional[int] 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: + if room and (self.width != old_width or force): self.rebuild_everything(room) # reposition the scrolling after resize @@ -155,11 +165,10 @@ class BaseTextWin(Win): if self.pos < 0: self.pos = 0 - # TODO: figure out the type of room. - def rebuild_everything(self, room) -> None: + def rebuild_everything(self, room: TextBuffer) -> None: self.built_lines = [] - with_timestamps = config.get('show_timestamps') - nick_size = config.get('max_nick_length') + with_timestamps = config.getbool('show_timestamps') + nick_size = config.getint('max_nick_length') for message in room.messages: self.build_new_message( message, @@ -167,34 +176,43 @@ class BaseTextWin(Win): timestamp=with_timestamps, nick_size=nick_size) if self.separator_after is message: - self.build_new_message(None) + self.built_lines.append(None) while len(self.built_lines) > self.lines_nb_limit: self.built_lines.pop(0) + def remove_line_separator(self) -> None: + """ + 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: TextBuffer = None) -> 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("Resetting number of highlights after separator") + if room and room.messages: + self.separator_after = room.messages[-1] + + def write_line_separator(self, y) -> None: + theme = get_theme() + char = theme.CHAR_NEW_TEXT_SEPARATOR + self.addnstr(y, 0, char * (self.width // len(char) - 1), self.width, + to_curses_attr(theme.COLOR_NEW_TEXT_SEPARATOR)) + def __del__(self) -> None: log.debug('** TextWin: deleting %s built lines', (len(self.built_lines))) del self.built_lines - -class TextWin(BaseTextWin): - __slots__ = ('highlights', 'hl_pos', 'nb_of_highlights_after_separator') - - def __init__(self, lines_nb_limit: Optional[int] = None) -> None: - BaseTextWin.__init__(self, lines_nb_limit) - - # the Lines of the highlights in that buffer - self.highlights = [] # type: List[Line] - # 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 - def next_highlight(self) -> None: """ Go to the next highlight in the buffer. @@ -203,13 +221,13 @@ class TextWin(BaseTextWin): 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 + if (not self.highlights or self.hl_pos is None or self.hl_pos >= len(self.highlights) - 1): - self.hl_pos = float('nan') + self.hl_pos = None self.pos = 0 return hl_size = len(self.highlights) - 1 - if self.hl_pos < hl_size: + if self.hl_pos is not None and self.hl_pos < hl_size: self.hl_pos += 1 else: self.hl_pos = hl_size @@ -220,9 +238,10 @@ class TextWin(BaseTextWin): try: pos = self.built_lines.index(hl) except ValueError: - self.highlights = self.highlights[self.hl_pos + 1:] + if isinstance(self.hl_pos, int): + del self.highlights[self.hl_pos] if not self.highlights: - self.hl_pos = float('nan') + self.hl_pos = None self.pos = 0 return self.hl_pos = 0 @@ -239,11 +258,11 @@ class TextWin(BaseTextWin): 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') + if not self.highlights or self.hl_pos and self.hl_pos <= 0: + self.hl_pos = None self.pos = 0 return - if self.hl_pos != self.hl_pos: + if self.hl_pos is None: self.hl_pos = len(self.highlights) - 1 else: self.hl_pos -= 1 @@ -254,9 +273,10 @@ class TextWin(BaseTextWin): try: pos = self.built_lines.index(hl) except ValueError: - self.highlights = self.highlights[self.hl_pos + 1:] + if self.hl_pos is not None: + del self.highlights[self.hl_pos] if not self.highlights: - self.hl_pos = float('nan') + self.hl_pos = None self.pos = 0 return self.hl_pos = 0 @@ -267,8 +287,8 @@ class TextWin(BaseTextWin): def scroll_to_separator(self) -> None: """ - Scroll until separator is centered. If no separator is - present, scroll at the top of the window + Scroll to the first message after the separator. If no + separator is present, scroll to the first message of the window """ if None in self.built_lines: self.pos = len(self.built_lines) - self.built_lines.index( @@ -286,371 +306,31 @@ class TextWin(BaseTextWin): self.highlights) - self.nb_of_highlights_after_separator - 1 log.debug("self.hl_pos = %s", self.hl_pos) - def remove_line_separator(self) -> None: - """ - Remove the line separator - """ - log.debug('remove_line_separator') - if None in self.built_lines: - self.built_lines.remove(None) - self.separator_after = None - - # TODO: figure out the type of room. - def add_line_separator(self, room=None) -> 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("Resetting number of highlights after separator") - if room and room.messages: - self.separator_after = room.messages[-1] - - # TODO: figure out the type of history. - def build_new_message(self, - message: Message, - history=None, - clean: bool = True, - highlight: bool = False, - timestamp: bool = False, - nick_size: int = 10) -> int: - """ - 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: Optional[Message], timestamp: bool = False, nick_size: int = 10) -> List[Union[None, Line]]: - """ - 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) + '}') # type: Optional[str] - else: - default_color = None - ret = [] # type: List[Union[None, Line]] - 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 = [] # type: List[str] - 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) -> None: - 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) -> int: - 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) -> int: - 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) -> int: - 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) -> None: - 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) -> int: - 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) -> int: - 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) -> None: - """ - 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) -> None: """ 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') + with_timestamps = config.getbool('show_timestamps') + nick_size = config.getint('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: + current = self.built_lines[i] + if current is not None and current.msg.identifier == old_id: index = i - while index >= 0 and self.built_lines[index] and self.built_lines[index].msg.identifier == old_id: + while ( + index >= 0 + and current is not None + and current.msg.identifier == old_id + ): self.built_lines.pop(index) index -= 1 + if index >= 0: + current = self.built_lines[index] index += 1 - lines = self.build_message( - message, timestamp=with_timestamps, nick_size=nick_size) + lines = build_lines( + message, self.width, timestamp=with_timestamps, nick_size=nick_size + ) for line in lines: self.built_lines.insert(index, line) index += 1 break - - def __del__(self) -> None: - log.debug('** TextWin: deleting %s built lines', - (len(self.built_lines))) - del self.built_lines - - -class XMLTextWin(BaseTextWin): - __slots__ = () - - def __init__(self) -> None: - BaseTextWin.__init__(self) - - def refresh(self) -> None: - 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: Message, timestamp: bool = False, nick_size: int = 10) -> List[Line]: - 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 = [] # type: List[str] - 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) -> None: - self._win.attron(to_curses_attr(color)) - self.addstr(truncate_nick(nickname)) - self._win.attroff(to_curses_attr(color)) diff --git a/poezio/xdg.py b/poezio/xdg.py index 0b63998c..d7ff9d73 100644 --- a/poezio/xdg.py +++ b/poezio/xdg.py @@ -3,7 +3,7 @@ # 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. +# it under the terms of the GPL-3.0+ license. See the COPYING file. """ Implements the XDG base directory specification. @@ -15,11 +15,11 @@ from os import environ from typing import Dict # $HOME has already been checked to not be None in test_env(). -DEFAULT_PATHS = { +DEFAULT_PATHS: Dict[str, Path] = { 'XDG_CONFIG_HOME': Path.home() / '.config', 'XDG_DATA_HOME': Path.home() / '.local' / 'share', 'XDG_CACHE_HOME': Path.home() / '.cache', -} # type: Dict[str, Path] +} def _get_directory(variable: str) -> Path: diff --git a/poezio/xhtml.py b/poezio/xhtml.py index 899985ef..2875f1a1 100644 --- a/poezio/xhtml.py +++ b/poezio/xhtml.py @@ -3,7 +3,7 @@ # 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. +# it under the terms of the GPL-3.0+ license. See the COPYING file. """ Various methods to convert shell colors to poezio colors, @@ -21,6 +21,7 @@ from pathlib import Path from io import BytesIO from xml import sax from xml.sax import saxutils +from xml.sax.handler import ContentHandler from typing import List, Dict, Optional, Union, Tuple from slixmpp.xmlstream import ET @@ -32,7 +33,7 @@ digits = '0123456789' # never trust the modules XHTML_NS = 'http://www.w3.org/1999/xhtml' # HTML named colors -colors = { +colors: Dict[str, int] = { 'aliceblue': 231, 'antiquewhite': 231, 'aqua': 51, @@ -180,7 +181,7 @@ colors = { 'whitesmoke': 255, 'yellow': 226, 'yellowgreen': 149 -} # type: Dict[str, int] +} whitespace_re = re.compile(r'\s+') @@ -299,21 +300,21 @@ def get_hash(data: bytes) -> str: b'/', b'-').decode() -class XHTMLHandler(sax.ContentHandler): +class XHTMLHandler(ContentHandler): def __init__(self, force_ns=False, tmp_image_dir: Optional[Path] = None) -> None: - self.builder = [] # type: List[str] - self.formatting = [] # type: List[str] - self.attrs = [] # type: List[Dict[str, str]] - self.list_state = [] # type: List[Union[str, int]] - self.cids = {} # type: Dict[str, Optional[str]] + self.builder: List[str] = [] + self.formatting: List[str] = [] + self.attrs: List[Dict[str, str]] = [] + self.list_state: List[Union[str, int]] = [] + self.cids: Dict[str, Optional[str]] = {} self.is_pre = False self.a_start = 0 # do not care about xhtml-in namespace self.force_ns = force_ns self.tmp_image_dir = Path(tmp_image_dir) if tmp_image_dir else None - self.enable_css_parsing = config.get('enable_css_parsing') + self.enable_css_parsing = config.getbool('enable_css_parsing') @property def result(self) -> str: @@ -430,7 +431,7 @@ class XHTMLHandler(sax.ContentHandler): if 'href' in attrs and attrs['href'] != link_text: builder.append(' (%s)' % _trim(attrs['href'])) elif name == 'blockquote': - builder.append('”') + builder.append('”\n') elif name in ('cite', 'em', 'strong'): self.pop_formatting() elif name in ('ol', 'p', 'ul'): @@ -488,7 +489,7 @@ def convert_simple_to_full_colors(text: str) -> str: a \x19n} formatted one. """ # TODO, have a single list of this. This is some sort of - # duplicate from windows.format_chars + # duplicate from ui.consts.FORMAT_CHARS mapping = str.maketrans({ '\x0E': '\x19b', '\x0F': '\x19o', @@ -512,7 +513,7 @@ def convert_simple_to_full_colors(text: str) -> str: return re.sub(xhtml_simple_attr_re, add_curly_bracket, text) -number_to_color_names = { +number_to_color_names: Dict[int, str] = { 1: 'red', 2: 'green', 3: 'yellow', @@ -520,7 +521,7 @@ number_to_color_names = { 5: 'violet', 6: 'turquoise', 7: 'white' -} # type: Dict[int, str] +} def format_inline_css(_dict: Dict[str, str]) -> str: @@ -535,7 +536,7 @@ def poezio_colors_to_html(string: str) -> str: # 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 = {} # type: Dict[str, str] + current_attrs: Dict[str, str] = {} tag_open = False next_attr_char = string.find('\x19') build = ["<body xmlns='http://www.w3.org/1999/xhtml'><p>"] |