summaryrefslogtreecommitdiff
path: root/poezio
diff options
context:
space:
mode:
Diffstat (limited to 'poezio')
-rw-r--r--poezio/args.py61
-rw-r--r--poezio/asyncio_fix.py (renamed from poezio/asyncio.py)0
-rw-r--r--poezio/bookmarks.py161
-rw-r--r--poezio/colors.py50
-rw-r--r--poezio/common.py141
-rw-r--r--poezio/config.py293
-rw-r--r--poezio/connection.py94
-rw-r--r--poezio/contact.py65
-rw-r--r--poezio/core/command_defs.py452
-rw-r--r--poezio/core/commands.py857
-rw-r--r--poezio/core/completions.py130
-rw-r--r--poezio/core/core.py1024
-rw-r--r--poezio/core/handlers.py974
-rw-r--r--poezio/core/structs.py81
-rw-r--r--poezio/core/tabs.py98
-rwxr-xr-xpoezio/daemon.py2
-rw-r--r--poezio/decorators.py169
-rw-r--r--poezio/events.py113
-rw-r--r--poezio/fixes.py35
-rw-r--r--poezio/hsluv.py360
-rwxr-xr-xpoezio/keyboard.py6
-rw-r--r--poezio/log_loader.py395
-rw-r--r--poezio/logger.py361
-rw-r--r--poezio/mam.py211
-rw-r--r--poezio/multiuserchat.py258
-rw-r--r--poezio/pep.py207
-rw-r--r--poezio/plugin.py54
-rw-r--r--poezio/plugin_e2ee.py685
-rw-r--r--poezio/plugin_manager.py99
-rw-r--r--poezio/poezio.py37
-rw-r--r--poezio/poezio_shlex.pyi45
-rw-r--r--poezio/poopt.py185
-rw-r--r--poezio/poopt.pyi7
-rw-r--r--poezio/pooptmodule.c2
-rw-r--r--poezio/py.typed0
-rw-r--r--poezio/roster.py60
-rw-r--r--poezio/size_manager.py12
-rw-r--r--poezio/tabs/adhoc_commands_list.py4
-rw-r--r--poezio/tabs/basetabs.py549
-rw-r--r--poezio/tabs/bookmarkstab.py48
-rw-r--r--poezio/tabs/confirmtab.py6
-rw-r--r--poezio/tabs/conversationtab.py288
-rw-r--r--poezio/tabs/data_forms.py6
-rw-r--r--poezio/tabs/listtab.py8
-rw-r--r--poezio/tabs/muclisttab.py8
-rw-r--r--poezio/tabs/muctab.py1461
-rw-r--r--poezio/tabs/privatetab.py338
-rw-r--r--poezio/tabs/rostertab.py717
-rw-r--r--poezio/tabs/xmltab.py67
-rw-r--r--poezio/text_buffer.py336
-rwxr-xr-xpoezio/theming.py65
-rw-r--r--poezio/timed_events.py10
-rw-r--r--poezio/types.py8
-rw-r--r--poezio/ui/__init__.py0
-rw-r--r--poezio/ui/consts.py4
-rw-r--r--poezio/ui/funcs.py (renamed from poezio/windows/funcs.py)14
-rw-r--r--poezio/ui/render.py280
-rw-r--r--poezio/ui/types.py260
-rw-r--r--poezio/user.py44
-rw-r--r--poezio/utils.py21
-rw-r--r--poezio/version.py2
-rw-r--r--poezio/windows/__init__.py4
-rw-r--r--poezio/windows/base_wins.py60
-rw-r--r--poezio/windows/bookmark_forms.py96
-rw-r--r--poezio/windows/data_forms.py39
-rw-r--r--poezio/windows/image.py56
-rw-r--r--poezio/windows/info_bar.py103
-rw-r--r--poezio/windows/info_wins.py153
-rw-r--r--poezio/windows/input_placeholders.py2
-rw-r--r--poezio/windows/inputs.py50
-rw-r--r--poezio/windows/list.py24
-rw-r--r--poezio/windows/misc.py8
-rw-r--r--poezio/windows/muc.py22
-rw-r--r--poezio/windows/roster_win.py116
-rw-r--r--poezio/windows/text_win.py578
-rw-r--r--poezio/xdg.py6
-rw-r--r--poezio/xhtml.py31
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>"]