diff options
-rw-r--r-- | data/doap.xml | 2 | ||||
-rw-r--r-- | doc/source/dev/plugin.rst | 11 | ||||
-rw-r--r-- | plugins/display_corrections.py | 2 | ||||
-rw-r--r-- | plugins/embed.py | 12 | ||||
-rwxr-xr-x | plugins/qr.py | 178 | ||||
-rw-r--r-- | plugins/reorder.py | 29 | ||||
-rw-r--r-- | plugins/upload.py | 22 | ||||
-rw-r--r-- | poezio/core/commands.py | 4 | ||||
-rw-r--r-- | poezio/core/core.py | 7 | ||||
-rw-r--r-- | poezio/core/handlers.py | 23 | ||||
-rw-r--r-- | poezio/multiuserchat.py | 2 | ||||
-rw-r--r-- | poezio/plugin.py | 8 | ||||
-rw-r--r-- | poezio/plugin_manager.py | 50 | ||||
-rw-r--r-- | poezio/tabs/basetabs.py | 13 | ||||
-rw-r--r-- | poezio/tabs/conversationtab.py | 2 | ||||
-rw-r--r-- | poezio/tabs/muctab.py | 88 | ||||
-rw-r--r-- | poezio/tabs/privatetab.py | 2 | ||||
-rw-r--r-- | poezio/text_buffer.py | 38 | ||||
-rwxr-xr-x | poezio/theming.py | 5 |
19 files changed, 434 insertions, 64 deletions
diff --git a/data/doap.xml b/data/doap.xml index f41c9fea..f8317ef7 100644 --- a/data/doap.xml +++ b/data/doap.xml @@ -423,7 +423,7 @@ <xmpp:SupportedXep> <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0308.html"/> <xmpp:status>complete</xmpp:status> - <xmpp:version>1.0</xmpp:version> + <xmpp:version>1.1.0</xmpp:version> <xmpp:since>0.8</xmpp:since> </xmpp:SupportedXep> </implements> diff --git a/doc/source/dev/plugin.rst b/doc/source/dev/plugin.rst index 6a7605b2..4614c761 100644 --- a/doc/source/dev/plugin.rst +++ b/doc/source/dev/plugin.rst @@ -27,7 +27,6 @@ BasePlugin .. module:: poezio.plugin .. autoclass:: BasePlugin - :members: init, cleanup, api, core .. method:: init(self) @@ -49,6 +48,16 @@ BasePlugin The :py:class:`~PluginAPI` instance for this plugin. + .. attribute:: dependencies + + Dependencies on other plugins, as a set of strings. A reference + to each dependency will be added in ``refs``. + + .. attribute:: refs + + This attribute is not to be edited by the user. It will be + populated when the plugin is initialized with references on each + plugin specified in the ``dependencies`` attribute. Each plugin inheriting :py:class:`~BasePlugin` has an ``api`` member variable, which refers to a :py:class:`~PluginAPI` object. diff --git a/plugins/display_corrections.py b/plugins/display_corrections.py index e9e8a2e4..99982ec9 100644 --- a/plugins/display_corrections.py +++ b/plugins/display_corrections.py @@ -43,7 +43,7 @@ class Plugin(BasePlugin): messages = self.api.get_conversation_messages() if not messages: return None - for message in messages[::-1]: + for message in reversed(messages): if message.old_message: if nb == 1: return message diff --git a/plugins/embed.py b/plugins/embed.py index 9895a927..0c4a4a2a 100644 --- a/plugins/embed.py +++ b/plugins/embed.py @@ -28,14 +28,13 @@ class Plugin(BasePlugin): help='Embed an image url into the contact\'s client', usage='<image_url>') - def embed_image_url(self, args): + def embed_image_url(self, url): tab = self.api.current_tab() message = self.core.xmpp.make_message(tab.jid) - message['body'] = args - message['oob']['url'] = args - if isinstance(tab, tabs.MucTab): - message['type'] = 'groupchat' - else: + message['body'] = url + message['oob']['url'] = url + message['type'] = 'groupchat' + if not isinstance(tab, tabs.MucTab): message['type'] = 'chat' tab.add_message( message['body'], @@ -46,3 +45,4 @@ class Plugin(BasePlugin): typ=1, ) message.send() + self.core.refresh_window() diff --git a/plugins/qr.py b/plugins/qr.py new file mode 100755 index 00000000..25530248 --- /dev/null +++ b/plugins/qr.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 + +import io +import logging +import qrcode +import sys + +from poezio import windows +from poezio.tabs import Tab +from poezio.common import safeJID +from poezio.core.structs import Command +from poezio.decorators import command_args_parser +from poezio.plugin import BasePlugin +from poezio.theming import get_theme, to_curses_attr +from poezio.windows.base_wins import Win + +log = logging.getLogger(__name__) + +class QrWindow(Win): + __slots__ = ('qr', 'invert', 'inverted') + + str_invert = " Invert " + str_close = " Close " + + def __init__(self, qr: str) -> None: + self.qr = qr + self.invert = True + self.inverted = True + + def refresh(self) -> None: + self._win.erase() + # draw QR code + code = qrcode.QRCode() + code.add_data(self.qr) + out = io.StringIO() + code.print_ascii(out, invert=self.inverted) + self.addstr(" " + self.qr + "\n") + self.addstr(out.getvalue(), to_curses_attr((15, 0))) + self.addstr(" ") + + col = to_curses_attr(get_theme().COLOR_TAB_NORMAL) + + if self.invert: + self.addstr(self.str_invert, col) + else: + self.addstr(self.str_invert) + + self.addstr(" ") + + if self.invert: + self.addstr(self.str_close) + else: + self.addstr(self.str_close, col) + + self._refresh() + + def toggle_choice(self) -> None: + self.invert = not self.invert + + def engage(self) -> bool: + if self.invert: + self.inverted = not self.inverted + return False + else: + return True + +class QrTab(Tab): + plugin_commands = {} # type: Dict[str, Command] + plugin_keys = {} # type: Dict[str, Callable] + + def __init__(self, core, qr): + Tab.__init__(self, core) + self.state = 'highlight' + self.text = qr + self.name = qr + self.topic_win = windows.Topic() + self.topic_win.set_message(qr) + self.qr_win = QrWindow(qr) + self.help_win = windows.HelpText( + "Choose with arrow keys and press enter") + self.key_func['^I'] = self.toggle_choice + self.key_func[' '] = self.toggle_choice + self.key_func['KEY_LEFT'] = self.toggle_choice + self.key_func['KEY_RIGHT'] = self.toggle_choice + self.key_func['^M'] = self.engage + self.resize() + self.update_commands() + self.update_keys() + + def resize(self): + self.need_resize = False + self.topic_win.resize(1, self.width, 0, 0) + self.qr_win.resize(self.height-3, self.width, 1, 0) + self.help_win.resize(1, self.width, self.height-1, 0) + + def refresh(self): + if self.need_resize: + self.resize() + log.debug(' TAB Refresh: %s', self.__class__.__name__) + self.refresh_tab_win() + self.info_win.refresh() + self.topic_win.refresh() + self.qr_win.refresh() + self.help_win.refresh() + + def on_input(self, key, raw): + if not raw and key in self.key_func: + return self.key_func[key]() + + def toggle_choice(self): + log.debug(' TAB toggle_choice: %s', self.__class__.__name__) + self.qr_win.toggle_choice() + self.refresh() + self.core.doupdate() + + def engage(self): + log.debug(' TAB engage: %s', self.__class__.__name__) + if self.qr_win.engage(): + self.core.close_tab(self) + else: + self.refresh() + self.core.doupdate() + +class Plugin(BasePlugin): + def init(self): + self.api.add_command( + 'qr', + self.command_qr, + usage='<message>', + short='Display a QR code', + help='Display a QR code of <message> in a new tab') + self.api.add_command( + 'invitation', + self.command_invite, + usage='[<server>]', + short='Invite a user', + help='Generate a XEP-0401 invitation on your server or on <server> and display a QR code') + + def command_qr(self, msg): + t = QrTab(self.core, msg) + self.core.add_tab(t, True) + self.core.doupdate() + + def on_next(self, iq, adhoc_session): + status = iq['command']['status'] + xform = iq.xml.find( + '{http://jabber.org/protocol/commands}command/{jabber:x:data}x') + if xform is not None: + form = self.core.xmpp.plugin['xep_0004'].build_form(xform) + else: + form = None + uri = None + if status == 'completed' and form: + for field in form: + log.debug(' field: %s -> %s', field['var'], field['value']) + if field['var'] == 'landing-url' and field['value']: + uri = field.get_value(convert=False) + if field['var'] == 'uri' and field['value'] and uri is None: + uri = field.get_value(convert=False) + if uri: + t = QrTab(self.core, uri) + self.core.add_tab(t, True) + self.core.doupdate() + else: + self.core.handler.next_adhoc_step(iq, adhoc_session) + + + @command_args_parser.quoted(0, 1, defaults=[]) + def command_invite(self, args): + server = self.core.xmpp.boundjid.domain + if len(args) > 0: + server = safeJID(args[0]) + session = { + 'next' : self.on_next, + 'error': self.core.handler.adhoc_error + } + self.core.xmpp.plugin['xep_0050'].start_command(server, 'urn:xmpp:invite#invite', session) + diff --git a/plugins/reorder.py b/plugins/reorder.py index 8d9516f8..7be0b350 100644 --- a/plugins/reorder.py +++ b/plugins/reorder.py @@ -59,6 +59,8 @@ And finally, the ``[tab name]`` must be: - For a type ``static``, the full JID of the contact """ +from slixmpp import InvalidJID, JID + from poezio import tabs from poezio.decorators import command_args_parser from poezio.plugin import BasePlugin @@ -162,21 +164,32 @@ class Plugin(BasePlugin): new_tabs += [ tabs.GapTab(self.core) for i in range(pos - last - 1) ] - cls, name = tabs_spec[pos] - tab = self.core.tabs.by_name_and_class(name, cls=cls) - if tab and tab in old_tabs: - new_tabs.append(tab) - old_tabs.remove(tab) - else: - self.api.information('Tab %s not found' % name, 'Warning') + cls, jid = tabs_spec[pos] + try: + jid = JID(jid) + tab = self.core.tabs.by_name_and_class(str(jid), cls=cls) + if tab and tab in old_tabs: + new_tabs.append(tab) + old_tabs.remove(tab) + else: + self.api.information('Tab %s not found. Creating it' % jid, 'Warning') + # TODO: Add support for MucTab. Requires nickname. + if cls in (tabs.DynamicConversationTab, tabs.StaticConversationTab): + new_tab = cls(self.core, jid) + new_tabs.append(new_tab) + except: + self.api.information('Failed to create tab \'%s\'.' % jid, 'Error') if create_gaps: new_tabs.append(tabs.GapTab(self.core)) - last = pos + finally: + last = pos for tab in old_tabs: if tab: new_tabs.append(tab) + # TODO: Ensure we don't break poezio and call this with whatever + # tablist we have. The roster tab at least needs to be in there. self.core.tabs.replace_tabs(new_tabs) self.core.refresh_window() diff --git a/plugins/upload.py b/plugins/upload.py index 7e25070e..5e6dfb04 100644 --- a/plugins/upload.py +++ b/plugins/upload.py @@ -16,6 +16,9 @@ This plugin adds a command to the chat tabs. """ + +from typing import Optional + import asyncio import traceback from os.path import expanduser @@ -30,7 +33,11 @@ from poezio import tabs class Plugin(BasePlugin): + dependencies = {'embed'} + def init(self): + self.embed = self.refs['embed'] + if not self.core.xmpp['xep_0363']: raise Exception('slixmpp XEP-0363 plugin failed to load') for _class in (tabs.PrivateTab, tabs.StaticConversationTab, tabs.DynamicConversationTab, tabs.MucTab): @@ -43,18 +50,23 @@ class Plugin(BasePlugin): short='Upload a file', completion=self.completion_filename) - async def async_upload(self, filename): + async def upload(self, filename) -> Optional[str]: try: url = await self.core.xmpp['xep_0363'].upload_file(filename) except UploadServiceNotFound: self.api.information('HTTP Upload service not found.', 'Error') - return + return None except Exception: exception = traceback.format_exc() self.api.information('Failed to upload file: %s' % exception, 'Error') - return - self.core.insert_input_text(url) + return None + return url + + async def send_upload(self, filename): + url = await self.upload(filename) + if url is not None: + self.embed.embed_image_url(url) @command_args_parser.quoted(1) def command_upload(self, args): @@ -63,7 +75,7 @@ class Plugin(BasePlugin): return filename, = args filename = expanduser(filename) - asyncio.ensure_future(self.async_upload(filename)) + asyncio.ensure_future(self.send_upload(filename)) @staticmethod def completion_filename(the_input): diff --git a/poezio/core/commands.py b/poezio/core/commands.py index fca9a705..b00cf24a 100644 --- a/poezio/core/commands.py +++ b/poezio/core/commands.py @@ -3,7 +3,7 @@ Global commands which are to be linked to the Core class """ import asyncio -from xml.etree import cElementTree as ET +from xml.etree import ElementTree as ET from typing import List, Optional, Tuple import logging @@ -1035,9 +1035,9 @@ class CommandCore: 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: List[str]) -> None: diff --git a/poezio/core/core.py b/poezio/core/core.py index fe6a9d78..14852ac2 100644 --- a/poezio/core/core.py +++ b/poezio/core/core.py @@ -16,7 +16,7 @@ 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 xml.etree import ElementTree as ET from functools import partial from slixmpp import JID, InvalidJID @@ -227,6 +227,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), @@ -517,10 +518,10 @@ class Core: plugins = config.get('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): diff --git a/poezio/core/handlers.py b/poezio/core/handlers.py index cfdeb271..1078916f 100644 --- a/poezio/core/handlers.py +++ b/poezio/core/handlers.py @@ -553,7 +553,7 @@ class HandlerCore: return item = message['pubsub_event']['items']['item'] old_gaming = contact.gaming - if item.xml.find('{urn:xmpp:gaming:0}gaming') is not None: + if item.xml.find('{urn:xmpp:gaming:0}game') is not None: item = item['gaming'] # only name and server_address are used for now contact.gaming = { @@ -770,7 +770,7 @@ class HandlerCore: self.core.events.trigger('highlight', message, tab) if message['from'].resource == tab.own_nick: - tab.last_sent_message = message + tab.set_last_sent_message(message, correct=replaced) if tab is self.core.tabs.current_tab: tab.text_win.refresh() @@ -862,7 +862,7 @@ class HandlerCore: jid=message['from'], typ=1) if sent: - tab.last_sent_message = message + tab.set_last_sent_message(message, correct=replaced) else: tab.last_remote_message = datetime.now() @@ -1116,7 +1116,7 @@ class HandlerCore: 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): @@ -1247,8 +1247,15 @@ class HandlerCore: '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() + + async def on_reconnect_delay(self, event): + """ + When the reconnection is delayed + """ + self.core.information("Reconnecting in %d seconds..." % (event), 'Info') def on_stream_error(self, event): """ @@ -1504,6 +1511,10 @@ class HandlerCore: poezio_colored, nickname=char) except: + # Most of the time what gets logged is whitespace pings. Skip. + # And also skip tab updates. + if stanza.strip() == '': + return None log.debug('', exc_info=True) if isinstance(self.core.tabs.current_tab, tabs.XMLTab): diff --git a/poezio/multiuserchat.py b/poezio/multiuserchat.py index 47244e3d..30c36a77 100644 --- a/poezio/multiuserchat.py +++ b/poezio/multiuserchat.py @@ -10,7 +10,7 @@ Add some facilities that are not available on the XEP_0045 slix plugin """ -from xml.etree import cElementTree as ET +from xml.etree import ElementTree as ET from poezio.common import safeJID from slixmpp import JID diff --git a/poezio/plugin.py b/poezio/plugin.py index 61e0ea87..0ba13412 100644 --- a/poezio/plugin.py +++ b/poezio/plugin.py @@ -3,6 +3,8 @@ 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 from asyncio import iscoroutinefunction from functools import partial from configparser import RawConfigParser @@ -399,7 +401,13 @@ class BasePlugin(object, metaclass=SafetyMetaclass): Class that all plugins derive from. """ + # Internal use only + _unloading = False + default_config = None + dependencies: Set[str] = set() + # This dict will get populated when the plugin is initialized + refs: Dict[str, Any] = {} def __init__(self, name, plugin_api, core, plugins_conf_dir): self.__name = name diff --git a/poezio/plugin_manager.py b/poezio/plugin_manager.py index e603b6fa..bf708089 100644 --- a/poezio/plugin_manager.py +++ b/poezio/plugin_manager.py @@ -7,6 +7,7 @@ plugin env. import logging import os +from typing import Dict, Set from importlib import import_module, machinery from pathlib import Path from os import path @@ -27,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 @@ -58,10 +61,25 @@ class PluginManager: for plugin in set(self.plugins.keys()): self.unload(plugin, notify=False) - def load(self, name: str, 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) @@ -83,7 +101,7 @@ class PluginManager: log.debug('Found candidate entry for plugin %s: %r', name, entry) try: module = entry.load() - except ImportError as exn: + except Exception as exn: log.debug('Failed to import plugin: %s\n%r', name, exn, exc_info=True) finally: @@ -109,8 +127,22 @@ class PluginManager: self.event_handlers[name] = [] try: self.plugins[name] = None + + 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: @@ -122,8 +154,21 @@ class PluginManager: self.core.information('Plugin %s loaded' % name, 'Info') 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(): @@ -143,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] diff --git a/poezio/tabs/basetabs.py b/poezio/tabs/basetabs.py index 7749de6c..706172ed 100644 --- a/poezio/tabs/basetabs.py +++ b/poezio/tabs/basetabs.py @@ -20,7 +20,7 @@ import asyncio import time from math import ceil, log10 from datetime import datetime -from xml.etree import cElementTree as ET +from xml.etree import ElementTree as ET from typing import ( Any, Callable, @@ -609,7 +609,7 @@ class ChatTab(Tab): message = self._text_buffer.modify_message( txt, old_id, new_id, time=time, user=user, jid=jid) if message: - self.text_win.modify_message(old_id, message) + self.text_win.modify_message(message.identifier, message) self.core.refresh_window() return True return False @@ -748,6 +748,15 @@ class ChatTab(Tab): self.core.remove_timed_event(self.timed_event_not_paused) self.timed_event_not_paused = None + def set_last_sent_message(self, msg, correct=False): + """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 = copy.copy(msg) + msg['id'] = self.last_sent_message['id'] + self.last_sent_message = msg + @command_args_parser.raw def command_correct(self, line): """ diff --git a/poezio/tabs/conversationtab.py b/poezio/tabs/conversationtab.py index 39411872..410c5eda 100644 --- a/poezio/tabs/conversationtab.py +++ b/poezio/tabs/conversationtab.py @@ -139,7 +139,7 @@ class ConversationTab(OneToOneTab): self.core.events.trigger('conversation_say_after', msg, self) if not msg['body']: return - self.last_sent_message = msg + self.set_last_sent_message(msg, correct=correct) self.core.handler.on_normal_message(msg) msg._add_receipt = True msg.send() diff --git a/poezio/tabs/muctab.py b/poezio/tabs/muctab.py index 3e754ae6..92dc1e51 100644 --- a/poezio/tabs/muctab.py +++ b/poezio/tabs/muctab.py @@ -7,6 +7,7 @@ It keeps track of many things such as part/joins, maintains an user list, and updates private tabs when necessary. """ +import asyncio import bisect import curses import logging @@ -20,6 +21,7 @@ from datetime import datetime from typing import Dict, Callable, List, Optional, Union, Set from slixmpp import InvalidJID, JID +from slixmpp.exceptions import IqError, IqTimeout from poezio.tabs import ChatTab, Tab, SHOW_NAME from poezio import common @@ -1127,7 +1129,7 @@ class MucTab(ChatTab): user=user, jid=jid) if message: - self.text_win.modify_message(old_id, message) + self.text_win.modify_message(message.identifier, message) return highlight return False @@ -1596,24 +1598,90 @@ class MucTab(ChatTab): nick, role, reason = args[0], args[1].lower(), args[2] self.change_role(nick, role, reason) - @command_args_parser.quoted(2) - def command_affiliation(self, args): + @command_args_parser.quoted(0, 2) + def command_affiliation(self, args) -> None: """ - /affiliation <nick or jid> <affiliation> + /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.jid.bare) + room = JID(self.name) + if not room: + self.core.information('affiliation: requires a valid chat address', 'Error') + return - if args is None: + # List affiliations + if not args: + asyncio.ensure_future(self.get_users_affiliations(room)) + return None + + if len(args) != 2: return self.core.command.help('affiliation') nick, affiliation = args[0], args[1].lower() + # Set affiliation self.change_affiliation(nick, affiliation) + async def get_users_affiliations(self, jid: JID) -> None: + MUC_ADMIN_NS = 'http://jabber.org/protocol/muc#admin' + + iqs = await asyncio.gather( + self.core.xmpp['xep_0045'].get_users_by_affiliation(jid, 'owner'), + self.core.xmpp['xep_0045'].get_users_by_affiliation(jid, 'admin'), + self.core.xmpp['xep_0045'].get_users_by_affiliation(jid, 'member'), + self.core.xmpp['xep_0045'].get_users_by_affiliation(jid, 'outcast'), + return_exceptions=True, + ) + + all_errors = functools.reduce( + lambda acc, iq: acc and isinstance(iq, (IqError, IqTimeout)), + iqs, + True, + ) + + 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, + } + + if all_errors: + self.add_message( + 'Can\'t access affiliations', + highlight=True, + nickname='Error', + nick_color=theme.COLOR_ERROR_MSG, + typ=2, + ) + self.core.refresh_window() + return None + + self._text_buffer.add_message('Affiliations') + for iq in iqs: + if isinstance(iq, (IqError, IqTimeout)): + continue + + query = iq.xml.find('{%s}query' % MUC_ADMIN_NS) + items = query.findall('{%s}item' % MUC_ADMIN_NS) + if not items: # Nobody with this affiliation + continue + + affiliation = items[0].get('affiliation') + aff_char = aff_colors[affiliation] + self._text_buffer.add_message( + ' %s%s' % (aff_char, affiliation.capitalize()), + ) + + items = map(lambda i: i.get('jid'), items) + for ajid in sorted(items): + self._text_buffer.add_message(' %s' % ajid) + + self.core.refresh_window() + return None + @command_args_parser.raw def command_say(self, line, correct=False): """ @@ -1648,7 +1716,7 @@ class MucTab(ChatTab): self.text_win.refresh() self.input.refresh() return - self.last_sent_message = msg + self.set_last_sent_message(msg, correct=correct) msg.send() self.chat_state = needed @@ -1936,7 +2004,7 @@ class MucTab(ChatTab): 'func': self.command_affiliation, 'usage': - '<nick or jid> <affiliation>', + '[<nick or jid> [<affiliation>]]', 'desc': ('Set the affiliation of a user. Affiliations can be:' ' outcast, none, member, admin, owner.'), 'shortdesc': diff --git a/poezio/tabs/privatetab.py b/poezio/tabs/privatetab.py index 8d2c1b11..ee4cd84c 100644 --- a/poezio/tabs/privatetab.py +++ b/poezio/tabs/privatetab.py @@ -177,7 +177,7 @@ class PrivateTab(OneToOneTab): self.core.events.trigger('private_say_after', msg, self) if not msg['body']: return - self.last_sent_message = msg + self.set_last_sent_message(msg, correct=correct) self.core.handler.on_groupchat_private_message(msg, sent=True) msg._add_receipt = True msg.send() diff --git a/poezio/text_buffer.py b/poezio/text_buffer.py index d9347527..2c0d192a 100644 --- a/poezio/text_buffer.py +++ b/poezio/text_buffer.py @@ -11,7 +11,7 @@ independently by their TextWins. import logging log = logging.getLogger(__name__) -from typing import Union, Optional, List, Tuple +from typing import Dict, Union, Optional, List, Tuple from datetime import datetime from poezio.config import config from poezio.theming import get_theme, dump_tuple @@ -121,6 +121,8 @@ class TextBuffer: self._messages_nb_limit = messages_nb_limit # type: int # Message objects self.messages = [] # type: List[Message] + # COMPAT: Correction id -> Original message id. + self.correction_ids = {} # type: 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 @@ -186,15 +188,20 @@ 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]: """Mark a message as acked""" @@ -211,7 +218,7 @@ class TextBuffer: 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] @@ -228,7 +235,7 @@ class TextBuffer: def modify_message(self, txt: str, - old_id: str, + orig_id: str, new_id: str, highlight: bool = False, time: Optional[datetime] = None, @@ -236,14 +243,19 @@ class TextBuffer: jid: Optional[str] = None): """ 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] @@ -258,10 +270,12 @@ 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 + + self.correction_ids[new_id] = orig_id message = Message( txt, time, @@ -269,13 +283,13 @@ class TextBuffer: msg.nick_color, False, msg.user, - new_id, + 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: diff --git a/poezio/theming.py b/poezio/theming.py index bbf2fb64..fc34ae39 100755 --- a/poezio/theming.py +++ b/poezio/theming.py @@ -178,12 +178,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 ' |