diff options
Diffstat (limited to 'poezio/core')
-rw-r--r-- | poezio/core/command_defs.py | 452 | ||||
-rw-r--r-- | poezio/core/commands.py | 868 | ||||
-rw-r--r-- | poezio/core/completions.py | 139 | ||||
-rw-r--r-- | poezio/core/core.py | 1068 | ||||
-rw-r--r-- | poezio/core/handlers.py | 989 | ||||
-rw-r--r-- | poezio/core/structs.py | 81 | ||||
-rw-r--r-- | poezio/core/tabs.py | 98 |
7 files changed, 2149 insertions, 1546 deletions
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 5c8199c0..fe91ca67 100644 --- a/poezio/core/commands.py +++ b/poezio/core/commands.py @@ -2,37 +2,46 @@ Global commands which are to be linked to the Core class """ +import asyncio +from urllib.parse import unquote +from xml.etree import ElementTree as ET +from typing import List, Optional, Tuple import logging -log = logging.getLogger(__name__) - -from xml.etree import cElementTree as ET - -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): """ @@ -131,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 @@ -149,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: @@ -215,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): """ @@ -256,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 @@ -264,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: @@ -320,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: @@ -330,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] """ @@ -349,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 @@ -376,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() @@ -390,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 @@ -450,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 @@ -467,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): @@ -485,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 = [] @@ -524,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( @@ -532,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') @@ -552,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) @@ -579,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') @@ -631,144 +884,119 @@ 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: + """/impromptu <jid> [<jid> ...]""" + + if args is None: + return self.help('impromptu') + + jids = set() + current_tab = self.core.tabs.current_tab + if isinstance(current_tab, tabs.ConversationTab): + jids.add(current_tab.general_jid) + + for jid in common.shell_split(' '.join(args)): + try: + bare = JID(jid).bare + except InvalidJID: + return self.core.information('Invalid JID for /impromptu: %s' % args[0], 'Error') + jids.add(JID(bare)) + + 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] @@ -776,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): @@ -802,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): @@ -884,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) @@ -897,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) @@ -910,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, @@ -937,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): @@ -949,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): @@ -971,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 @@ -981,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 b283950e..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,37 +302,23 @@ 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) - 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""" + def impromptu(self, the_input): + """Completion for /impromptu""" n = the_input.get_argument_position(quoted=True) - if n == 1: - return Completion( - the_input.new_completion, - sorted(pep.MOODS.keys()), - 1, - quotify=True) + onlines = [] + offlines = [] + for barejid in roster.jids(): + if len(roster[barejid]): + onlines.append(barejid) + else: + offlines.append(barejid) + comp = sorted(onlines) + sorted(offlines) + return Completion(the_input.new_completion, comp, n, quotify=True) def last_activity(self, the_input): """ @@ -333,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): @@ -429,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) @@ -446,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 eec0d49b..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 @@ -14,24 +16,46 @@ import sys import shutil import time from collections import defaultdict -from typing import Callable, Dict, List, Optional, Tuple, Type - -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, 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 @@ -42,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) @@ -88,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() @@ -104,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) @@ -155,10 +220,12 @@ class Core: "KEY_F(5)": self.rotate_rooms_left, "^P": self.rotate_rooms_left, "M-[-D": self.rotate_rooms_left, + "M-[1;3D": self.rotate_rooms_left, 'kLFT3': self.rotate_rooms_left, "KEY_F(6)": self.rotate_rooms_right, "^N": self.rotate_rooms_right, "M-[-C": self.rotate_rooms_right, + "M-[1;3C": self.rotate_rooms_right, 'kRIT3': self.rotate_rooms_right, "KEY_F(4)": self.toggle_left_pane, "KEY_F(7)": self.shrink_information_win, @@ -196,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'), @@ -203,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), @@ -221,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), @@ -234,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), @@ -250,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), @@ -257,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]} @@ -301,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), @@ -318,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), @@ -327,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() @@ -368,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 @@ -375,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): """ @@ -419,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 @@ -490,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) @@ -504,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): @@ -519,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' @@ -552,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 @@ -594,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: @@ -607,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 @@ -629,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() @@ -648,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() @@ -705,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: @@ -779,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 """ @@ -801,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) @@ -816,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 @@ -826,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: """ @@ -843,81 +926,180 @@ 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) - - 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 + # 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) -> Iq: + fields = [ + ('hidden', 'FORM_TYPE', 'http://jabber.org/protocol/muc#roomconfig'), + ('boolean', 'muc#roomconfig_changesubject', True), + ('boolean', 'muc#roomconfig_allowinvites', True), + ('boolean', 'muc#roomconfig_persistent', True), + ('boolean', 'muc#roomconfig_membersonly', True), + ('boolean', 'muc#roomconfig_publicroom', False), + ('list-single', 'muc#roomconfig_whois', 'anyone'), + # MAM + ('boolean', 'muc#roomconfig_enablearchiving', True), # Prosody + ('boolean', 'mam', True), # Ejabberd community + ('boolean', 'muc#roomconfig_mam', True), # Ejabberd saas + ] + + form = self.xmpp['xep_0004'].make_form() + form['type'] = 'submit' + for field in fields: + form.add_field( + ftype=field[0], + var=field[1], + value=field[2], + ) + + iq = self.xmpp.Iq() + iq['type'] = 'set' + iq['to'] = room + query = ET.Element('{http://jabber.org/protocol/muc#owner}query') + query.append(form.xml) + iq.append(query) + return iq + + async def impromptu(self, jids: Set[JID]) -> None: + """ + Generates a new "Impromptu" room with a random localpart on the muc + component of the user who initiated the request. One the room is + created and the first user has joined, send invites for specified + contacts to join in. + """ + + results = await self.xmpp['xep_0030'].get_info_from_domain() + + muc_from_identity = '' + for info in results: + for identity in info['disco_info']['identities']: + if identity[0] == 'conference' and identity[1] == 'text': + muc_from_identity = info['from'].bare + + # Use config.default_muc_service as muc component if available, + # otherwise find muc component by disco#items-ing the user domain. + # If not, give up + default_muc = config.get('default_muc_service', muc_from_identity) + if not default_muc: + self.information( + "Error finding a MUC service to join. If your server does not " + "provide one, set 'default_muc_service' manually to a MUC " + "service that allows room creation.", + 'Error' + ) + return + + # 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 + + 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.open_new_room(room, self.own_nick).join() + + 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 + + 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 @@ -926,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 @@ -965,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: """ @@ -1007,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()) @@ -1018,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: @@ -1047,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) @@ -1058,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) @@ -1082,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 @@ -1128,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) @@ -1151,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) @@ -1161,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) @@ -1173,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) @@ -1184,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() @@ -1221,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() @@ -1231,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: @@ -1408,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 @@ -1420,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 @@ -1445,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 @@ -1503,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 """ @@ -1511,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() @@ -1569,335 +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( - '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' @@ -1906,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() @@ -1946,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 0e655d68..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,96 +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 """ + 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.create_task( + self.core.xmpp['xep_0030'].get_info_from_domain(), + ) + + def is_known_muc_pm(self, message: Message, with_jid: JID) -> Optional[bool]: + """ + Try to determine whether a given message is a MUC-PM, without a roundtrip. Returns None when it's not clear + """ + + # 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 + + 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 - 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) + # 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 - self.core.xmpp.plugin['xep_0030'].get_info( - jid=self.core.xmpp.boundjid.domain, callback=callback) + return None - def on_carbon_received(self, message): + async def on_carbon_received(self, message: Message): """ Carbon <received/> received """ - - def ignore_message(recv): - log.debug('%s has category conference, ignoring carbon', - recv['from'].server) - - def receive_message(recv): - recv['to'] = self.core.xmpp.boundjid.full - if recv['receipt']: - return self.on_receipt(recv) - self.on_normal_message(recv) - recv = message['carbon_received'] - if (recv['from'].bare not in roster - or roster[recv['from'].bare].subscription == 'none'): - fixes.has_identity( - self.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'] @@ -177,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 @@ -208,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 @@ -216,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: @@ -255,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) @@ -279,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: @@ -379,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: @@ -429,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: @@ -465,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] @@ -479,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 @@ -663,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) @@ -752,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: @@ -813,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() @@ -855,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 """ @@ -863,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() @@ -872,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 """ @@ -880,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: @@ -898,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. """ @@ -915,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. """ @@ -927,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. """ @@ -946,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] @@ -969,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] @@ -984,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] @@ -997,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] @@ -1010,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): @@ -1018,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] @@ -1034,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( @@ -1046,24 +783,24 @@ 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'] - if not logger.log_roster_change(jid.bare, 'got offline'): + status = presence['status'] + if not logger.log_roster_change(jid.bare, 'got offline{}'.format(' ({})'.format(status) if status else '')): self.core.information('Unable to write in the log file', 'Error') # If a resource got offline, display the message in the conversation with this # precise resource. @@ -1073,23 +810,25 @@ class HandlerCore: roster.connected -= 1 if contact.name: name = contact.name + offline_msg = '%s is \x191}offline' % name + if status: + offline_msg += ' (\x19o%s\x191})' % status if jid.resource: self.core.add_information_message_to_conversation_tab( - jid.full, '\x195}%s is \x191}offline' % name) + jid.full, '\x195}' + offline_msg) self.core.add_information_message_to_conversation_tab( - jid.bare, '\x195}%s is \x191}offline' % name) - self.core.information('\x193}%s \x195}is \x191}offline' % name, + jid.bare, '\x195}' + offline_msg) + self.core.information('\x193}' + offline_msg, 'Roster') roster.modified() 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] @@ -1106,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) @@ -1124,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 @@ -1133,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() + + 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): + async def on_stream_error(self, event): """ When we receive a stream error """ @@ -1179,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 """ @@ -1187,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) """ @@ -1195,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 """ @@ -1217,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. @@ -1261,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. """ @@ -1338,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) @@ -1366,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) """ @@ -1404,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. """ @@ -1468,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): @@ -1516,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( @@ -1637,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') @@ -1670,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 |