diff options
author | Emmanuel Gil Peyrot <linkmauve@linkmauve.fr> | 2016-03-31 18:54:41 +0100 |
---|---|---|
committer | Emmanuel Gil Peyrot <linkmauve@linkmauve.fr> | 2016-06-11 20:49:43 +0100 |
commit | 332a5c2553db41de777473a1e1be9cd1522c9496 (patch) | |
tree | 3ee06a59f147ccc4009b35cccfbe2461bcd18310 /poezio/tabs | |
parent | cf44cf7cdec9fdb35caa372563d57e7045dc29dd (diff) | |
download | poezio-332a5c2553db41de777473a1e1be9cd1522c9496.tar.gz poezio-332a5c2553db41de777473a1e1be9cd1522c9496.tar.bz2 poezio-332a5c2553db41de777473a1e1be9cd1522c9496.tar.xz poezio-332a5c2553db41de777473a1e1be9cd1522c9496.zip |
Move the src directory to poezio, for better cython compatibility.
Diffstat (limited to 'poezio/tabs')
-rw-r--r-- | poezio/tabs/__init__.py | 13 | ||||
-rw-r--r-- | poezio/tabs/adhoc_commands_list.py | 57 | ||||
-rw-r--r-- | poezio/tabs/basetabs.py | 881 | ||||
-rw-r--r-- | poezio/tabs/bookmarkstab.py | 145 | ||||
-rw-r--r-- | poezio/tabs/conversationtab.py | 484 | ||||
-rw-r--r-- | poezio/tabs/data_forms.py | 75 | ||||
-rw-r--r-- | poezio/tabs/listtab.py | 202 | ||||
-rw-r--r-- | poezio/tabs/muclisttab.py | 70 | ||||
-rw-r--r-- | poezio/tabs/muctab.py | 1720 | ||||
-rw-r--r-- | poezio/tabs/privatetab.py | 362 | ||||
-rw-r--r-- | poezio/tabs/rostertab.py | 1280 | ||||
-rw-r--r-- | poezio/tabs/xmltab.py | 360 |
12 files changed, 5649 insertions, 0 deletions
diff --git a/poezio/tabs/__init__.py b/poezio/tabs/__init__.py new file mode 100644 index 00000000..d0a881a6 --- /dev/null +++ b/poezio/tabs/__init__.py @@ -0,0 +1,13 @@ +from . basetabs import Tab, ChatTab, GapTab, OneToOneTab +from . basetabs import STATE_PRIORITY +from . rostertab import RosterInfoTab +from . muctab import MucTab, NS_MUC_USER +from . privatetab import PrivateTab +from . conversationtab import ConversationTab, StaticConversationTab,\ + DynamicConversationTab +from . xmltab import XMLTab +from . listtab import ListTab +from . muclisttab import MucListTab +from . adhoc_commands_list import AdhocCommandsListTab +from . data_forms import DataFormsTab +from . bookmarkstab import BookmarksTab diff --git a/poezio/tabs/adhoc_commands_list.py b/poezio/tabs/adhoc_commands_list.py new file mode 100644 index 00000000..10ebf22b --- /dev/null +++ b/poezio/tabs/adhoc_commands_list.py @@ -0,0 +1,57 @@ +""" +A tab listing the ad-hoc commands on a specific JID. The user can +select one of them and start executing it, or just close the tab and do +nothing. +""" + +import logging +log = logging.getLogger(__name__) + +from . import ListTab + +from slixmpp.plugins.xep_0030.stanza.items import DiscoItem + +class AdhocCommandsListTab(ListTab): + plugin_commands = {} + plugin_keys = {} + + def __init__(self, jid): + ListTab.__init__(self, jid.full, + "“Enter”: execute selected command.", + 'Ad-hoc commands of JID %s (Loading)' % jid, + (('Node', 0), ('Description', 1))) + self.key_func['^M'] = self.execute_selected_command + + def execute_selected_command(self): + if not self.listview or not self.listview.get_selected_row(): + return + node, name, jid = self.listview.get_selected_row() + session = {'next': self.core.on_next_adhoc_step, + 'error': self.core.on_adhoc_error} + self.core.xmpp.plugin['xep_0050'].start_command(jid, node, session) + + def get_columns_sizes(self): + return {'Node': int(self.width * 3 / 8), + 'Description': int(self.width * 5 / 8)} + + def on_list_received(self, iq): + """ + Fill the listview with the value from the received iq + """ + if iq['type'] == 'error': + self.set_error(iq['error']['type'], iq['error']['code'], iq['error']['text']) + return + def get_items(): + substanza = iq['disco_items'] + for item in substanza['substanzas']: + if isinstance(item, DiscoItem): + yield item + items = [(item['node'], item['name'] or '', item['jid']) for item in get_items()] + self.listview.set_lines(items) + self.info_header.message = 'Ad-hoc commands of JID %s' % self.name + if self.core.current_tab() is self: + self.refresh() + else: + self.state = 'highlight' + self.refresh_tab_win() + self.core.doupdate() diff --git a/poezio/tabs/basetabs.py b/poezio/tabs/basetabs.py new file mode 100644 index 00000000..bb0c0ea4 --- /dev/null +++ b/poezio/tabs/basetabs.py @@ -0,0 +1,881 @@ +""" +Module for the base Tabs + +The root class Tab defines the generic interface and attributes of a +tab. A tab organizes various Windows around the screen depending +of the tab specificity. If the tab shows messages, it will also +reference a buffer containing the messages. + +Each subclass should redefine its own refresh() and resize() method +according to its windows. + +This module also defines ChatTabs, the parent class for all tabs +revolving around chats. +""" + +import logging +log = logging.getLogger(__name__) + +import singleton +import string +import time +import weakref +from datetime import datetime, timedelta +from xml.etree import cElementTree as ET + +import core +import timed_events +import windows +import xhtml +from common import safeJID +from config import config +from decorators import refresh_wrapper +from logger import logger +from text_buffer import TextBuffer +from theming import get_theme, dump_tuple +from decorators import command_args_parser + +# getters for tab colors (lambdas, so that they are dynamic) +STATE_COLORS = { + 'disconnected': lambda: get_theme().COLOR_TAB_DISCONNECTED, + 'scrolled': lambda: get_theme().COLOR_TAB_SCROLLED, + 'nonempty': lambda: get_theme().COLOR_TAB_NONEMPTY, + 'joined': lambda: get_theme().COLOR_TAB_JOINED, + 'message': lambda: get_theme().COLOR_TAB_NEW_MESSAGE, + 'composing': lambda: get_theme().COLOR_TAB_COMPOSING, + 'highlight': lambda: get_theme().COLOR_TAB_HIGHLIGHT, + 'private': lambda: get_theme().COLOR_TAB_PRIVATE, + 'normal': lambda: get_theme().COLOR_TAB_NORMAL, + 'current': lambda: get_theme().COLOR_TAB_CURRENT, + 'attention': lambda: get_theme().COLOR_TAB_ATTENTION, + } +VERTICAL_STATE_COLORS = { + 'disconnected': lambda: get_theme().COLOR_VERTICAL_TAB_DISCONNECTED, + 'scrolled': lambda: get_theme().COLOR_VERTICAL_TAB_SCROLLED, + 'nonempty': lambda: get_theme().COLOR_VERTICAL_TAB_NONEMPTY, + 'joined': lambda: get_theme().COLOR_VERTICAL_TAB_JOINED, + 'message': lambda: get_theme().COLOR_VERTICAL_TAB_NEW_MESSAGE, + 'composing': lambda: get_theme().COLOR_VERTICAL_TAB_COMPOSING, + 'highlight': lambda: get_theme().COLOR_VERTICAL_TAB_HIGHLIGHT, + 'private': lambda: get_theme().COLOR_VERTICAL_TAB_PRIVATE, + 'normal': lambda: get_theme().COLOR_VERTICAL_TAB_NORMAL, + 'current': lambda: get_theme().COLOR_VERTICAL_TAB_CURRENT, + 'attention': lambda: get_theme().COLOR_VERTICAL_TAB_ATTENTION, + } + + +# priority of the different tab states when using Alt+e +# higher means more priority, < 0 means not selectable +STATE_PRIORITY = { + 'normal': -1, + 'current': -1, + 'disconnected': 0, + 'nonempty': 0.1, + 'scrolled': 0.5, + 'joined': 0.8, + 'composing': 0.9, + 'message': 1, + 'highlight': 2, + 'private': 2, + 'attention': 3 + } + +class Tab(object): + tab_core = None + size_manager = None + + plugin_commands = {} + plugin_keys = {} + def __init__(self): + if not hasattr(self, 'name'): + self.name = self.__class__.__name__ + self.input = None + self.closed = False + self._state = 'normal' + self._prev_state = None + + self.need_resize = False + self.key_func = {} # each tab should add their keys in there + # and use them in on_input + self.commands = {} # and their own commands + + + @property + def size(self): + if not Tab.size_manager: + Tab.size_manager = self.core.size + return Tab.size_manager + + @property + def core(self): + if not Tab.tab_core: + Tab.tab_core = singleton.Singleton(core.Core) + return Tab.tab_core + + @property + def nb(self): + for index, tab in enumerate(self.core.tabs): + if tab == self: + return index + return len(self.core.tabs) + + @property + def tab_win(self): + if not Tab.tab_core: + Tab.tab_core = singleton.Singleton(core.Core) + return Tab.tab_core.tab_win + + @property + def left_tab_win(self): + if not Tab.tab_core: + Tab.tab_core = singleton.Singleton(core.Core) + return Tab.tab_core.left_tab_win + + @staticmethod + def tab_win_height(): + """ + Returns 1 or 0, depending on if we are using the vertical tab list + or not. + """ + if config.get('enable_vertical_tab_list'): + return 0 + return 1 + + @property + def info_win(self): + return self.core.information_win + + @property + def color(self): + return STATE_COLORS[self._state]() + + @property + def vertical_color(self): + return VERTICAL_STATE_COLORS[self._state]() + + @property + def state(self): + return self._state + + @state.setter + def state(self, value): + if not value in STATE_COLORS: + log.debug("Invalid value for tab state: %s", value) + elif STATE_PRIORITY[value] < STATE_PRIORITY[self._state] and \ + value not in ('current', 'disconnected') and \ + not (self._state == 'scrolled' and value == 'disconnected'): + log.debug("Did not set state because of lower priority, asked: %s, kept: %s", value, self._state) + elif self._state == 'disconnected' and value not in ('joined', 'current'): + log.debug('Did not set state because disconnected tabs remain visible') + else: + self._state = value + if self._state == 'current': + self._prev_state = None + + def set_state(self, value): + self._state = value + + def save_state(self): + if self._state != 'composing': + self._prev_state = self._state + + def restore_state(self): + if self.state == 'composing' and self._prev_state: + self._state = self._prev_state + self._prev_state = None + elif not self._prev_state: + self._state = 'normal' + + @staticmethod + def resize(scr): + Tab.height, Tab.width = scr.getmaxyx() + windows.Win._tab_win = scr + + def missing_command_callback(self, command_name): + """ + Callback executed when a command is not found. + Returns True if the callback took care of displaying + the error message, False otherwise. + """ + return False + + def register_command(self, name, func, *, desc='', shortdesc='', completion=None, usage=''): + """ + Add a command + """ + if name in self.commands: + return + if not desc and shortdesc: + desc = shortdesc + self.commands[name] = core.Command(func, desc, completion, shortdesc, usage) + + def complete_commands(self, the_input): + """ + Does command completion on the specified input for both global and tab-specific + commands. + This should be called from the completion method (on tab, for example), passing + the input where completion is to be made. + It can completion the command name itself or an argument of the command. + Returns True if a completion was made, False else. + """ + txt = the_input.get_text() + # check if this is a command + if txt.startswith('/') and not txt.startswith('//'): + position = the_input.get_argument_position(quoted=False) + if position == 0: + words = ['/%s'% (name) for name in sorted(self.core.commands)] +\ + ['/%s' % (name) for name in sorted(self.commands)] + the_input.new_completion(words, 0) + # Do not try to cycle command completion if there was only + # one possibily. The next tab will complete the argument. + # Otherwise we would need to add a useless space before being + # able to complete the arguments. + hit_copy = set(the_input.hit_list) + while not hit_copy: + whitespace = the_input.text.find(' ') + if whitespace == -1: + whitespace = len(the_input.text) + the_input.text = the_input.text[:whitespace-1] + the_input.text[whitespace:] + the_input.new_completion(words, 0) + hit_copy = set(the_input.hit_list) + if len(hit_copy) == 1: + the_input.do_command(' ') + the_input.reset_completion() + return True + # check if we are in the middle of the command name + elif len(txt.split()) > 1 or\ + (txt.endswith(' ') and not the_input.last_completion): + command_name = txt.split()[0][1:] + if command_name in self.commands: + command = self.commands[command_name] + elif command_name in self.core.commands: + command = self.core.commands[command_name] + else: # Unknown command, cannot complete + return False + if command[2] is None: + return False # There's no completion function + else: + return command[2](the_input) + return False + + def execute_command(self, provided_text): + """ + Execute the command in the input and return False if + the input didn't contain a command + """ + txt = provided_text or self.input.key_enter() + if txt.startswith('/') and not txt.startswith('//') and\ + not txt.startswith('/me '): + command = txt.strip().split()[0][1:] + arg = txt[2+len(command):] # jump the '/' and the ' ' + func = None + if command in self.commands: # check tab-specific commands + func = self.commands[command][0] + elif command in self.core.commands: # check global commands + func = self.core.commands[command][0] + else: + low = command.lower() + if low in self.commands: + func = self.commands[low][0] + elif low in self.core.commands: + func = self.core.commands[low][0] + else: + if self.missing_command_callback is not None: + error_handled = self.missing_command_callback(low) + if not error_handled: + self.core.information("Unknown command (%s)" % + (command), + 'Error') + if command in ('correct', 'say'): # hack + arg = xhtml.convert_simple_to_full_colors(arg) + else: + arg = xhtml.clean_text_simple(arg) + if func: + if hasattr(self.input, "reset_completion"): + self.input.reset_completion() + func(arg) + return True + else: + return False + + def refresh_tab_win(self): + if config.get('enable_vertical_tab_list'): + if self.left_tab_win and not self.size.core_degrade_x: + self.left_tab_win.refresh() + elif not self.size.core_degrade_y: + self.tab_win.refresh() + + def refresh(self): + """ + Called on each screen refresh (when something has changed) + """ + pass + + def get_name(self): + """ + get the name of the tab + """ + return self.name + + def get_nick(self): + """ + Get the nick of the tab (defaults to its name) + """ + return self.name + + def get_text_window(self): + """ + Returns the principal TextWin window, if there's one + """ + return None + + def on_input(self, key, raw): + """ + raw indicates if the key should activate the associated command or not. + """ + pass + + def update_commands(self): + for c in self.plugin_commands: + if not c in self.commands: + self.commands[c] = self.plugin_commands[c] + + def update_keys(self): + for k in self.plugin_keys: + if not k in self.key_func: + self.key_func[k] = self.plugin_keys[k] + + def on_lose_focus(self): + """ + called when this tab loses the focus. + """ + self.state = 'normal' + + def on_gain_focus(self): + """ + called when this tab gains the focus. + """ + self.state = 'current' + + def on_scroll_down(self): + """ + Defines what happens when we scroll down + """ + pass + + def on_scroll_up(self): + """ + Defines what happens when we scroll up + """ + pass + + def on_line_up(self): + """ + Defines what happens when we scroll one line up + """ + pass + + def on_line_down(self): + """ + Defines what happens when we scroll one line up + """ + pass + + def on_half_scroll_down(self): + """ + Defines what happens when we scroll half a screen down + """ + pass + + def on_half_scroll_up(self): + """ + Defines what happens when we scroll half a screen up + """ + pass + + def on_info_win_size_changed(self): + """ + Called when the window with the informations is resized + """ + pass + + def on_close(self): + """ + Called when the tab is to be closed + """ + if self.input: + self.input.on_delete() + self.closed = True + + def matching_names(self): + """ + Returns a list of strings that are used to name a tab with the /win + command. For example you could switch to a tab that returns + ['hello', 'coucou'] using /win hel, or /win coucou + If not implemented in the tab, it just doesn’t match with anything. + """ + return [] + + def __del__(self): + log.debug('------ Closing tab %s', self.__class__.__name__) + +class GapTab(Tab): + + def __bool__(self): + return False + + def __len__(self): + return 0 + + @property + def name(self): + return '' + + def refresh(self): + log.debug('WARNING: refresh() called on a gap tab, this should not happen') + +class ChatTab(Tab): + """ + A tab containing a chat of any type. + Just use this class instead of Tab if the tab needs a recent-words completion + Also, ^M is already bound to on_enter + And also, add the /say command + """ + plugin_commands = {} + plugin_keys = {} + def __init__(self, jid=''): + Tab.__init__(self) + self.name = jid + self.text_win = None + self._text_buffer = TextBuffer() + self.chatstate = None # can be "active", "composing", "paused", "gone", "inactive" + # We keep a reference of the event that will set our chatstate to "paused", so that + # we can delete it or change it if we need to + self.timed_event_paused = None + # Keeps the last sent message to complete it easily in completion_correct, and to replace it. + self.last_sent_message = None + self.key_func['M-v'] = self.move_separator + self.key_func['M-h'] = self.scroll_separator + self.key_func['M-/'] = self.last_words_completion + self.key_func['^M'] = self.on_enter + self.register_command('say', self.command_say, + usage='<message>', + shortdesc='Send the message.') + self.register_command('xhtml', self.command_xhtml, + usage='<custom xhtml>', + shortdesc='Send custom XHTML.') + self.register_command('clear', self.command_clear, + shortdesc='Clear the current buffer.') + self.register_command('correct', self.command_correct, + desc='Fix the last message with whatever you want.', + shortdesc='Correct the last message.', + completion=self.completion_correct) + self.chat_state = None + self.update_commands() + self.update_keys() + + # Get the logs + log_nb = config.get('load_log') + logs = self.load_logs(log_nb) + + if logs: + for message in logs: + self._text_buffer.add_message(**message) + + @property + def is_muc(self): + return False + + def load_logs(self, log_nb): + logs = logger.get_logs(safeJID(self.name).bare, log_nb) + return logs + + def log_message(self, txt, nickname, time=None, typ=1): + """ + Log the messages in the archives. + """ + name = safeJID(self.name).bare + if not logger.log_message(name, nickname, txt, date=time, typ=typ): + self.core.information('Unable to write in the log file', 'Error') + + def add_message(self, txt, time=None, nickname=None, forced_user=None, + nick_color=None, identifier=None, jid=None, history=None, + typ=1, highlight=False): + self.log_message(txt, nickname, time=time, typ=typ) + self._text_buffer.add_message(txt, time=time, + nickname=nickname, + highlight=highlight, + nick_color=nick_color, + history=history, + user=forced_user, + identifier=identifier, + jid=jid) + + def modify_message(self, txt, old_id, new_id, user=None, jid=None, nickname=None): + self.log_message(txt, nickname, typ=1) + message = self._text_buffer.modify_message(txt, old_id, new_id, time=time, user=user, jid=jid) + if message: + self.text_win.modify_message(old_id, message) + self.core.refresh_window() + return True + return False + + def last_words_completion(self): + """ + Complete the input with words recently said + """ + # build the list of the recent words + char_we_dont_want = string.punctuation+' ’„“”…«»' + words = list() + for msg in self._text_buffer.messages[:-40:-1]: + if not msg: + continue + txt = xhtml.clean_text(msg.txt) + for char in char_we_dont_want: + txt = txt.replace(char, ' ') + for word in txt.split(): + if len(word) >= 4 and word not in words: + words.append(word) + words.extend([word for word in config.get('words').split(':') if word]) + self.input.auto_completion(words, ' ', quotify=False) + + def on_enter(self): + txt = self.input.key_enter() + if txt: + if not self.execute_command(txt): + if txt.startswith('//'): + txt = txt[1:] + self.command_say(xhtml.convert_simple_to_full_colors(txt)) + self.cancel_paused_delay() + + @command_args_parser.raw + def command_xhtml(self, xhtml): + """" + /xhtml <custom xhtml> + """ + message = self.generate_xhtml_message(xhtml) + if message: + message.send() + + def generate_xhtml_message(self, arg): + if not arg: + return + try: + body = xhtml.clean_text(xhtml.xhtml_to_poezio_colors(arg)) + ET.fromstring(arg) + except: + self.core.information('Could not send custom xhtml', 'Error') + log.error('/xhtml: Unable to send custom xhtml', exc_info=True) + return + + msg = self.core.xmpp.make_message(self.get_dest_jid()) + msg['body'] = body + msg.enable('html') + msg['html']['body'] = arg + return msg + + def get_dest_jid(self): + return self.name + + @refresh_wrapper.always + def command_clear(self, ignored): + """ + /clear + """ + self._text_buffer.messages = [] + self.text_win.rebuild_everything(self._text_buffer) + + def send_chat_state(self, state, always_send=False): + """ + Send an empty chatstate message + """ + if not self.is_muc or self.joined: + if state in ('active', 'inactive', 'gone') and self.inactive and not always_send: + return + if (config.get_by_tabname('send_chat_states', self.general_jid) + and self.remote_wants_chatstates is not False): + msg = self.core.xmpp.make_message(self.get_dest_jid()) + msg['type'] = self.message_type + msg['chat_state'] = state + self.chat_state = state + msg.send() + return True + + def send_composing_chat_state(self, empty_after): + """ + Send the "active" or "composing" chatstate, depending + on the the current status of the input + """ + name = self.general_jid + if (config.get_by_tabname('send_chat_states', name) + and self.remote_wants_chatstates): + needed = 'inactive' if self.inactive else 'active' + self.cancel_paused_delay() + if not empty_after: + if self.chat_state != "composing": + self.send_chat_state("composing") + self.set_paused_delay(True) + elif empty_after and self.chat_state != needed: + self.send_chat_state(needed, True) + + def set_paused_delay(self, composing): + """ + we create a timed event that will put us to paused + in a few seconds + """ + if not config.get_by_tabname('send_chat_states', self.general_jid): + return + # First, cancel the delay if it already exists, before rescheduling + # it at a new date + self.cancel_paused_delay() + new_event = timed_events.DelayedEvent(4, self.send_chat_state, 'paused') + self.core.add_timed_event(new_event) + self.timed_event_paused = new_event + + def cancel_paused_delay(self): + """ + Remove that event from the list and set it to None. + Called for example when the input is emptied, or when the message + is sent + """ + if self.timed_event_paused is not None: + self.core.remove_timed_event(self.timed_event_paused) + self.timed_event_paused = None + + @command_args_parser.raw + def command_correct(self, line): + """ + /correct <fixed message> + """ + if not line: + self.core.command_help('correct') + return + if not self.last_sent_message: + self.core.information('There is no message to correct.') + return + self.command_say(line, correct=True) + + def completion_correct(self, the_input): + if self.last_sent_message and the_input.get_argument_position() == 1: + return the_input.auto_completion([self.last_sent_message['body']], '', quotify=False) + + @property + def inactive(self): + """Whether we should send inactive or active as a chatstate""" + return self.core.status.show in ('xa', 'away') or\ + (hasattr(self, 'directed_presence') and not self.directed_presence) + + def move_separator(self): + self.text_win.remove_line_separator() + self.text_win.add_line_separator(self._text_buffer) + self.text_win.refresh() + self.input.refresh() + + def get_conversation_messages(self): + return self._text_buffer.messages + + def check_scrolled(self): + if self.text_win.pos != 0: + self.state = 'scrolled' + + @command_args_parser.raw + def command_say(self, line, correct=False): + pass + + def on_line_up(self): + return self.text_win.scroll_up(1) + + def on_line_down(self): + return self.text_win.scroll_down(1) + + def on_scroll_up(self): + return self.text_win.scroll_up(self.text_win.height-1) + + def on_scroll_down(self): + return self.text_win.scroll_down(self.text_win.height-1) + + def on_half_scroll_up(self): + return self.text_win.scroll_up((self.text_win.height-1) // 2) + + def on_half_scroll_down(self): + return self.text_win.scroll_down((self.text_win.height-1) // 2) + + @refresh_wrapper.always + def scroll_separator(self): + self.text_win.scroll_to_separator() + +class OneToOneTab(ChatTab): + + def __init__(self, jid=''): + ChatTab.__init__(self, jid) + + # Set to true once the first disco is done + self.__initial_disco = False + # change this to True or False when + # we know that the remote user wants chatstates, or not. + # None means we don’t know yet, and we send only "active" chatstates + self._remote_wants_chatstates = None + self.remote_supports_attention = True + self.remote_supports_receipts = True + self.check_features() + + @property + def remote_wants_chatstates(self): + return self._remote_wants_chatstates + + @remote_wants_chatstates.setter + def remote_wants_chatstates(self, value): + old_value = self._remote_wants_chatstates + self._remote_wants_chatstates = value + if (old_value is None and value != None) or \ + (old_value != value and value != None): + ok = get_theme().CHAR_OK + nope = get_theme().CHAR_EMPTY + support = ok if value else nope + if value: + msg = '\x19%s}Contact supports chat states [%s].' + else: + msg = '\x19%s}Contact does not support chat states [%s].' + color = dump_tuple(get_theme().COLOR_INFORMATION_TEXT) + msg = msg % (color, support) + self.add_message(msg, typ=0) + self.core.refresh_window() + + def ack_message(self, msg_id, msg_jid): + """ + Ack a message + """ + new_msg = self._text_buffer.ack_message(msg_id, msg_jid) + if new_msg: + self.text_win.modify_message(msg_id, new_msg) + self.core.refresh_window() + + def nack_message(self, error, msg_id, msg_jid): + """ + Ack a message + """ + new_msg = self._text_buffer.nack_message(error, msg_id, msg_jid) + if new_msg: + self.text_win.modify_message(msg_id, new_msg) + self.core.refresh_window() + return True + return False + + @command_args_parser.raw + def command_xhtml(self, xhtml_data): + message = self.generate_xhtml_message(xhtml_data) + if message: + message['type'] = 'chat' + if self.remote_supports_receipts: + message._add_receipt = True + if self.remote_wants_chatstates: + message['chat_sate'] = 'active' + message.send() + body = xhtml.xhtml_to_poezio_colors(xhtml_data, force=True) + self._text_buffer.add_message(body, nickname=self.core.own_nick, + identifier=message['id'],) + self.refresh() + + def check_features(self): + "check the features supported by the other party" + if safeJID(self.get_dest_jid()).resource: + self.core.xmpp.plugin['xep_0030'].get_info( + jid=self.get_dest_jid(), timeout=5, + callback=self.features_checked) + + @command_args_parser.raw + def command_attention(self, message): + """/attention [message]""" + if message is not '': + self.command_say(message, attention=True) + else: + msg = self.core.xmpp.make_message(self.get_dest_jid()) + msg['type'] = 'chat' + msg['attention'] = True + msg.send() + + @command_args_parser.raw + def command_say(self, line, correct=False, attention=False): + pass + + def missing_command_callback(self, command_name): + if command_name not in ('correct', 'attention'): + return False + + if command_name == 'correct': + feature = 'message correction' + elif command_name == 'attention': + feature = 'attention requests' + msg = ('%s does not support %s, therefore the /%s ' + 'command is currently disabled in this tab.') + msg = msg % (self.name, feature, command_name) + self.core.information(msg, 'Info') + return True + + def _feature_attention(self, features): + "Check for the 'attention' features" + if 'urn:xmpp:attention:0' in features: + self.remote_supports_attention = True + self.register_command('attention', self.command_attention, + usage='[message]', + shortdesc='Request the attention.', + desc='Attention: Request the attention of ' + 'the contact. Can also send a message' + ' along with the attention.') + else: + self.remote_supports_attention = False + return self.remote_supports_attention + + def _feature_correct(self, features): + "Check for the 'correction' feature" + if not 'urn:xmpp:message-correct:0' in features: + if 'correct' in self.commands: + del self.commands['correct'] + elif not 'correct' in self.commands: + self.register_command('correct', self.command_correct, + desc='Fix the last message with whatever you want.', + shortdesc='Correct the last message.', + completion=self.completion_correct) + return 'correct' in self.commands + + def _feature_receipts(self, features): + "Check for the 'receipts' feature" + if 'urn:xmpp:receipts' in features: + self.remote_supports_receipts = True + else: + self.remote_supports_receipts = False + return self.remote_supports_receipts + + def features_checked(self, iq): + "Features check callback" + features = iq['disco_info'].get_features() or [] + before = ('correct' in self.commands, + self.remote_supports_attention, + self.remote_supports_receipts) + correct = self._feature_correct(features) + attention = self._feature_attention(features) + receipts = self._feature_receipts(features) + + if (correct, attention, receipts) == before and self.__initial_disco: + return + else: + self.__initial_disco = True + + if not (correct or attention or receipts): + return # don’t display anything + + ok = get_theme().CHAR_OK + nope = get_theme().CHAR_EMPTY + + correct = ok if correct else nope + attention = ok if attention else nope + receipts = ok if receipts else nope + + msg = ('\x19%s}Contact supports: correction [%s], ' + 'attention [%s], receipts [%s].') + color = dump_tuple(get_theme().COLOR_INFORMATION_TEXT) + msg = msg % (color, correct, attention, receipts) + self.add_message(msg, typ=0) + self.core.refresh_window() + + diff --git a/poezio/tabs/bookmarkstab.py b/poezio/tabs/bookmarkstab.py new file mode 100644 index 00000000..7f5069ea --- /dev/null +++ b/poezio/tabs/bookmarkstab.py @@ -0,0 +1,145 @@ +""" +Defines the data-forms Tab +""" + +import logging +log = logging.getLogger(__name__) + +import windows +from bookmarks import Bookmark, BookmarkList, stanza_storage +from tabs import Tab +from common import safeJID + + +class BookmarksTab(Tab): + """ + A tab displaying lines of bookmarks, each bookmark having + a 4 widgets to set the jid/password/autojoin/storage method + """ + plugin_commands = {} + def __init__(self, bookmarks: BookmarkList): + Tab.__init__(self) + self.name = "Bookmarks" + self.bookmarks = bookmarks + self.new_bookmarks = [] + self.removed_bookmarks = [] + self.header_win = windows.ColumnHeaderWin(('room@server/nickname', + 'password', + 'autojoin', + 'storage')) + self.bookmarks_win = windows.BookmarksWin(self.bookmarks, + self.height-4, + self.width, 1, 0) + self.help_win = windows.HelpText('Ctrl+Y: save, Ctrl+G: cancel, ' + '↑↓: change lines, tab: change ' + 'column, M-a: add bookmark, C-k' + ': delete bookmark') + self.info_header = windows.BookmarksInfoWin() + self.key_func['KEY_UP'] = self.bookmarks_win.go_to_previous_line_input + self.key_func['KEY_DOWN'] = self.bookmarks_win.go_to_next_line_input + self.key_func['^I'] = self.bookmarks_win.go_to_next_horizontal_input + self.key_func['^G'] = self.on_cancel + self.key_func['^Y'] = self.on_save + self.key_func['M-a'] = self.add_bookmark + self.key_func['^K'] = self.del_bookmark + self.resize() + self.update_commands() + + def add_bookmark(self): + new_bookmark = Bookmark(safeJID('room@example.tld/nick'), method='local') + self.new_bookmarks.append(new_bookmark) + self.bookmarks_win.add_bookmark(new_bookmark) + + def del_bookmark(self): + current = self.bookmarks_win.del_current_bookmark() + if current in self.new_bookmarks: + self.new_bookmarks.remove(current) + else: + self.removed_bookmarks.append(current) + + def on_cancel(self): + self.core.close_tab() + return True + + def on_save(self): + self.bookmarks_win.save() + if find_duplicates(self.new_bookmarks): + self.core.information('Duplicate bookmarks in list (saving aborted)', 'Error') + return + for bm in self.new_bookmarks: + if safeJID(bm.jid): + if not self.bookmarks[bm.jid]: + self.bookmarks.append(bm) + else: + self.core.information('Invalid JID for bookmark: %s/%s' % (bm.jid, bm.nick), 'Error') + return + + for bm in self.removed_bookmarks: + if bm in self.bookmarks: + self.bookmarks.remove(bm) + + def send_cb(success): + if success: + self.core.information('Bookmarks saved.', 'Info') + else: + self.core.information('Remote bookmarks not saved.', 'Error') + log.debug('alerte %s', str(stanza_storage(self.bookmarks.bookmarks))) + self.bookmarks.save(self.core.xmpp, callback=send_cb) + self.core.close_tab() + return True + + def on_input(self, key, raw=False): + if key in self.key_func: + res = self.key_func[key]() + if res: + return res + self.bookmarks_win.refresh_current_input() + else: + self.bookmarks_win.on_input(key) + + def resize(self): + self.need_resize = False + self.header_win.resize_columns({ + 'room@server/nickname': self.width//3, + 'password': self.width//3, + 'autojoin': self.width//6, + 'storage': self.width//6 + }) + info_height = self.core.information_win_size + tab_height = Tab.tab_win_height() + self.header_win.resize(1, self.width, 0, 0) + self.bookmarks_win.resize(self.height - 3 - tab_height - info_height, + self.width, 1, 0) + self.help_win.resize(1, self.width, self.height - 1, 0) + self.info_header.resize(1, self.width, + self.height - 2 - tab_height - info_height, 0) + + def on_info_win_size_changed(self): + if self.core.information_win_size >= self.height - 3: + return + info_height = self.core.information_win_size + tab_height = Tab.tab_win_height() + self.bookmarks_win.resize(self.height - 3 - tab_height - info_height, + self.width, 1, 0) + self.info_header.resize(1, self.width, + self.height - 2 - tab_height - info_height, 0) + + def refresh(self): + if self.need_resize: + self.resize() + self.header_win.refresh() + self.refresh_tab_win() + self.help_win.refresh() + self.info_header.refresh(self.bookmarks.preferred) + self.info_win.refresh() + self.bookmarks_win.refresh() + + +def find_duplicates(bm_list): + jids = set() + for bookmark in bm_list: + if bookmark.jid in jids: + return True + jids.add(bookmark.jid) + return False + diff --git a/poezio/tabs/conversationtab.py b/poezio/tabs/conversationtab.py new file mode 100644 index 00000000..1d8c60a4 --- /dev/null +++ b/poezio/tabs/conversationtab.py @@ -0,0 +1,484 @@ +""" +Module for the ConversationTabs + +A ConversationTab is a direct chat between two JIDs, outside of a room. + +There are two different instances of a ConversationTab: +- A DynamicConversationTab that implements XEP-0296 (best practices for + resource locking), which means it will switch the resource it is + focused on depending on the presences received. This is the default. +- A StaticConversationTab that will stay focused on one resource all + the time. + +""" +import logging +log = logging.getLogger(__name__) + +import curses + +from . basetabs import OneToOneTab, Tab + +import common +import fixes +import windows +import xhtml +from common import safeJID +from config import config +from decorators import refresh_wrapper +from roster import roster +from theming import get_theme, dump_tuple +from decorators import command_args_parser + +class ConversationTab(OneToOneTab): + """ + The tab containg a normal conversation (not from a MUC) + Must not be instantiated, use Static or Dynamic version only. + """ + plugin_commands = {} + plugin_keys = {} + additional_informations = {} + message_type = 'chat' + def __init__(self, jid): + OneToOneTab.__init__(self, jid) + self.nick = None + self.nick_sent = False + self.state = 'normal' + self.name = jid # a conversation tab is linked to one specific full jid OR bare jid + self.text_win = windows.TextWin() + self._text_buffer.add_window(self.text_win) + self.upper_bar = windows.ConversationStatusMessageWin() + self.input = windows.MessageInput() + # keys + self.key_func['^I'] = self.completion + # commands + self.register_command('unquery', self.command_unquery, + shortdesc='Close the tab.') + self.register_command('close', self.command_unquery, + shortdesc='Close the tab.') + self.register_command('version', self.command_version, + desc='Get the software version of the current interlocutor (usually its XMPP client and Operating System).', + shortdesc='Get the software version of the user.') + self.register_command('info', self.command_info, + shortdesc='Get the status of the contact.') + self.register_command('last_activity', self.command_last_activity, + usage='[jid]', + desc='Get the last activity of the given or the current contact.', + shortdesc='Get the activity.', + completion=self.core.completion_last_activity) + self.resize() + self.update_commands() + self.update_keys() + + @property + def general_jid(self): + return safeJID(self.name).bare + + @staticmethod + def add_information_element(plugin_name, callback): + """ + Lets a plugin add its own information to the ConversationInfoWin + """ + ConversationTab.additional_informations[plugin_name] = callback + + @staticmethod + def remove_information_element(plugin_name): + del ConversationTab.additional_informations[plugin_name] + + def completion(self): + self.complete_commands(self.input) + + @command_args_parser.raw + def command_say(self, line, attention=False, correct=False): + msg = self.core.xmpp.make_message(self.get_dest_jid()) + msg['type'] = 'chat' + msg['body'] = line + if not self.nick_sent: + msg['nick'] = self.core.own_nick + self.nick_sent = True + # trigger the event BEFORE looking for colors. + # and before displaying the message in the window + # This lets a plugin insert \x19xxx} colors, that will + # be converted in xhtml. + self.core.events.trigger('conversation_say', msg, self) + if not msg['body']: + self.cancel_paused_delay() + self.text_win.refresh() + self.input.refresh() + return + replaced = False + if correct or msg['replace']['id']: + msg['replace']['id'] = self.last_sent_message['id'] + if config.get_by_tabname('group_corrections', self.name): + try: + self.modify_message(msg['body'], self.last_sent_message['id'], msg['id'], jid=self.core.xmpp.boundjid, + nickname=self.core.own_nick) + replaced = True + except: + log.error('Unable to correct a message', exc_info=True) + else: + del msg['replace'] + if msg['body'].find('\x19') != -1: + msg.enable('html') + msg['html']['body'] = xhtml.poezio_colors_to_html(msg['body']) + msg['body'] = xhtml.clean_text(msg['body']) + if (config.get_by_tabname('send_chat_states', self.general_jid) and + self.remote_wants_chatstates is not False): + needed = 'inactive' if self.inactive else 'active' + msg['chat_state'] = needed + if attention and self.remote_supports_attention: + msg['attention'] = True + self.core.events.trigger('conversation_say_after', msg, self) + if not msg['body']: + self.cancel_paused_delay() + self.text_win.refresh() + self.input.refresh() + return + if not replaced: + self.add_message(msg['body'], + nickname=self.core.own_nick, + nick_color=get_theme().COLOR_OWN_NICK, + identifier=msg['id'], + jid=self.core.xmpp.boundjid, + typ=1) + + self.last_sent_message = msg + if self.remote_supports_receipts: + msg._add_receipt = True + msg.send() + self.cancel_paused_delay() + self.text_win.refresh() + self.input.refresh() + + @command_args_parser.quoted(0, 1) + def command_last_activity(self, args): + """ + /last_activity [jid] + """ + if args and args[0]: + return self.core.command_last_activity(args[0]) + + def callback(iq): + if iq['type'] != 'result': + if iq['error']['type'] == 'auth': + self.core.information('You are not allowed to see the activity of this contact.', 'Error') + else: + self.core.information('Error retrieving the activity', 'Error') + return + seconds = iq['last_activity']['seconds'] + status = iq['last_activity']['status'] + from_ = iq['from'] + msg = '\x19%s}The last activity of %s was %s ago%s' + if not safeJID(from_).user: + msg = '\x19%s}The uptime of %s is %s.' % ( + dump_tuple(get_theme().COLOR_INFORMATION_TEXT), + from_, + common.parse_secs_to_str(seconds)) + else: + msg = '\x19%s}The last activity of %s was %s ago%s' % ( + dump_tuple(get_theme().COLOR_INFORMATION_TEXT), + from_, + common.parse_secs_to_str(seconds), + (' and his/her last status was %s' % status) if status else '',) + self.add_message(msg) + self.core.refresh_window() + + self.core.xmpp.plugin['xep_0012'].get_last_activity(self.get_dest_jid(), callback=callback) + + @refresh_wrapper.conditional + @command_args_parser.ignored + def command_info(self): + contact = roster[self.get_dest_jid()] + jid = safeJID(self.get_dest_jid()) + if contact: + if jid.resource: + resource = contact[jid.full] + else: + resource = contact.get_highest_priority_resource() + else: + resource = None + if resource: + status = ('Status: %s' % resource.status) if resource.status else '' + self._text_buffer.add_message("\x19%(info_col)s}Show: %(show)s, %(status)s\x19o" % { + 'show': resource.show or 'available', 'status': status, 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)}) + return True + else: + self._text_buffer.add_message("\x19%(info_col)s}No information available\x19o" % {'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)}) + return True + + @command_args_parser.ignored + def command_unquery(self): + self.core.close_tab() + + @command_args_parser.quoted(0, 1) + def command_version(self, args): + """ + /version [jid] + """ + def callback(res): + if not res: + return self.core.information('Could not get the software version from %s' % (jid,), 'Warning') + version = '%s is running %s version %s on %s' % (jid, + res.get('name') or 'an unknown software', + res.get('version') or 'unknown', + res.get('os') or 'an unknown platform') + self.core.information(version, 'Info') + if args: + return self.core.command_version(args[0]) + jid = safeJID(self.name) + if not jid.resource: + if jid in roster: + resource = roster[jid].get_highest_priority_resource() + jid = resource.jid if resource else jid + fixes.get_version(self.core.xmpp, jid, + callback=callback) + + def resize(self): + self.need_resize = False + if self.size.tab_degrade_y: + display_bar = False + info_win_height = 0 + tab_win_height = 0 + bar_height = 0 + else: + display_bar = True + info_win_height = self.core.information_win_size + tab_win_height = Tab.tab_win_height() + bar_height = 1 + + self.text_win.resize(self.height - 2 - bar_height - info_win_height + - tab_win_height, + self.width, bar_height, 0) + self.text_win.rebuild_everything(self._text_buffer) + if display_bar: + self.upper_bar.resize(1, self.width, 0, 0) + self.info_header.resize(1, self.width, + self.height - 2 - info_win_height + - tab_win_height, + 0) + self.input.resize(1, self.width, self.height - 1, 0) + + def refresh(self): + if self.need_resize: + self.resize() + log.debug(' TAB Refresh: %s', self.__class__.__name__) + display_bar = display_info_win = not self.size.tab_degrade_y + + self.text_win.refresh() + + if display_bar: + self.upper_bar.refresh(self.get_dest_jid(), roster[self.get_dest_jid()]) + self.info_header.refresh(self.get_dest_jid(), roster[self.get_dest_jid()], self.text_win, self.chatstate, ConversationTab.additional_informations) + + if display_info_win: + self.info_win.refresh() + self.refresh_tab_win() + self.input.refresh() + + def refresh_info_header(self): + self.info_header.refresh(self.get_dest_jid(), roster[self.get_dest_jid()], + self.text_win, self.chatstate, ConversationTab.additional_informations) + self.input.refresh() + + def get_nick(self): + jid = safeJID(self.name) + contact = roster[jid.bare] + if contact: + return contact.name or jid.user + else: + if self.nick: + return self.nick + return jid.user + + def on_input(self, key, raw): + if not raw and key in self.key_func: + self.key_func[key]() + return False + self.input.do_command(key, raw=raw) + empty_after = self.input.get_text() == '' or (self.input.get_text().startswith('/') and not self.input.get_text().startswith('//')) + self.send_composing_chat_state(empty_after) + return False + + def on_lose_focus(self): + contact = roster[self.get_dest_jid()] + jid = safeJID(self.get_dest_jid()) + if contact: + if jid.resource: + resource = contact[jid.full] + else: + resource = contact.get_highest_priority_resource() + else: + resource = None + if self.input.text: + self.state = 'nonempty' + else: + self.state = 'normal' + self.text_win.remove_line_separator() + self.text_win.add_line_separator(self._text_buffer) + if (config.get_by_tabname('send_chat_states', self.general_jid) + and (not self.input.get_text() + or not self.input.get_text().startswith('//'))): + if resource: + self.send_chat_state('inactive') + self.check_scrolled() + + def on_gain_focus(self): + contact = roster[self.get_dest_jid()] + jid = safeJID(self.get_dest_jid()) + if contact: + if jid.resource: + resource = contact[jid.full] + else: + resource = contact.get_highest_priority_resource() + else: + resource = None + + self.state = 'current' + curses.curs_set(1) + if (config.get_by_tabname('send_chat_states', self.general_jid) + and (not self.input.get_text() + or not self.input.get_text().startswith('//'))): + if resource: + self.send_chat_state('active') + + def on_info_win_size_changed(self): + if self.core.information_win_size >= self.height-3: + return + self.text_win.resize(self.height-3-self.core.information_win_size - Tab.tab_win_height(), self.width, 1, 0) + self.info_header.resize(1, self.width, self.height-2-self.core.information_win_size - Tab.tab_win_height(), 0) + + def get_text_window(self): + return self.text_win + + def on_close(self): + Tab.on_close(self) + if config.get_by_tabname('send_chat_states', self.general_jid): + self.send_chat_state('gone') + + def matching_names(self): + res = [] + jid = safeJID(self.name) + res.append((2, jid.bare)) + res.append((1, jid.user)) + contact = roster[self.name] + if contact and contact.name: + res.append((0, contact.name)) + return res + +class DynamicConversationTab(ConversationTab): + """ + A conversation tab associated with one bare JID that can be “locked” to + a full jid, and unlocked, as described in the XEP-0296. + Only one DynamicConversationTab can be opened for a given jid. + """ + def __init__(self, jid, resource=None): + self.locked_resource = None + self.name = safeJID(jid).bare + if resource: + self.lock(resource) + self.info_header = windows.DynamicConversationInfoWin() + ConversationTab.__init__(self, jid) + self.register_command('unlock', self.unlock_command, + shortdesc='Unlock the conversation from a particular resource.') + + def lock(self, resource): + """ + Lock the tab to the resource. + """ + assert(resource) + if resource != self.locked_resource: + self.locked_resource = resource + info = '\x19%s}' % dump_tuple(get_theme().COLOR_INFORMATION_TEXT) + jid_c = '\x19%s}' % dump_tuple(get_theme().COLOR_MUC_JID) + + message = ('%(info)sConversation locked to ' + '%(jid_c)s%(jid)s/%(resource)s%(info)s.') % { + 'info': info, + 'jid_c': jid_c, + 'jid': self.name, + 'resource': resource} + self.add_message(message, typ=0) + self.check_features() + + def unlock_command(self, arg=None): + self.unlock() + self.refresh_info_header() + + def unlock(self, from_=None): + """ + Unlock the tab from a resource. It is now “associated” with the bare + jid. + """ + self.remote_wants_chatstates = None + if self.locked_resource != None: + self.locked_resource = None + info = '\x19%s}' % dump_tuple(get_theme().COLOR_INFORMATION_TEXT) + jid_c = '\x19%s}' % dump_tuple(get_theme().COLOR_MUC_JID) + + if from_: + message = ('%(info)sConversation unlocked (received activity' + ' from %(jid_c)s%(jid)s%(info)s).') % { + 'info': info, + 'jid_c': jid_c, + 'jid': from_} + self.add_message(message, typ=0) + else: + message = '%sConversation unlocked.' % info + self.add_message(message, typ=0) + + def get_dest_jid(self): + """ + Returns the full jid (using the locked resource), or the bare jid if + the conversation is not locked. + """ + if self.locked_resource: + return "%s/%s" % (self.name, self.locked_resource) + return self.name + + def refresh(self): + """ + Different from the parent class only for the info_header object. + """ + if self.need_resize: + self.resize() + log.debug(' TAB Refresh: %s', self.__class__.__name__) + display_bar = display_info_win = not self.size.tab_degrade_y + + self.text_win.refresh() + if display_bar: + self.upper_bar.refresh(self.name, roster[self.name]) + if self.locked_resource: + displayed_jid = "%s/%s" % (self.name, self.locked_resource) + else: + displayed_jid = self.name + self.info_header.refresh(displayed_jid, roster[self.name], + self.text_win, self.chatstate, + ConversationTab.additional_informations) + if display_info_win: + self.info_win.refresh() + + self.refresh_tab_win() + self.input.refresh() + + def refresh_info_header(self): + """ + Different from the parent class only for the info_header object. + """ + if self.locked_resource: + displayed_jid = "%s/%s" % (self.name, self.locked_resource) + else: + displayed_jid = self.name + self.info_header.refresh(displayed_jid, roster[self.name], + self.text_win, self.chatstate, ConversationTab.additional_informations) + self.input.refresh() + +class StaticConversationTab(ConversationTab): + """ + A conversation tab associated with one Full JID. It cannot be locked to + an different resource or unlocked. + """ + def __init__(self, jid): + assert(safeJID(jid).resource) + self.info_header = windows.ConversationInfoWin() + ConversationTab.__init__(self, jid) + + diff --git a/poezio/tabs/data_forms.py b/poezio/tabs/data_forms.py new file mode 100644 index 00000000..0fad2974 --- /dev/null +++ b/poezio/tabs/data_forms.py @@ -0,0 +1,75 @@ +""" +Defines the data-forms Tab +""" + +import logging +log = logging.getLogger(__name__) + +import windows +from tabs import Tab + +class DataFormsTab(Tab): + """ + A tab contaning various window type, displaying + a form that the user needs to fill. + """ + plugin_commands = {} + def __init__(self, form, on_cancel, on_send, kwargs): + Tab.__init__(self) + self._form = form + self._on_cancel = on_cancel + self._on_send = on_send + self._kwargs = kwargs + self.fields = [] + for field in self._form: + self.fields.append(field) + self.topic_win = windows.Topic() + self.form_win = windows.FormWin(form, self.height-4, self.width, 1, 0) + self.help_win = windows.HelpText("Ctrl+Y: send form, Ctrl+G: cancel") + self.help_win_dyn = windows.HelpText() + self.key_func['KEY_UP'] = self.form_win.go_to_previous_input + self.key_func['KEY_DOWN'] = self.form_win.go_to_next_input + self.key_func['^G'] = self.on_cancel + self.key_func['^Y'] = self.on_send + self.resize() + self.update_commands() + + def on_cancel(self): + self._on_cancel(self._form, **self._kwargs) + return True + + def on_send(self): + self._form.reply() + self.form_win.reply() + self._on_send(self._form, **self._kwargs) + return True + + def on_input(self, key, raw=False): + if key in self.key_func: + res = self.key_func[key]() + if res: + return res + self.help_win_dyn.refresh(self.form_win.get_help_message()) + self.form_win.refresh_current_input() + else: + self.form_win.on_input(key) + + def resize(self): + self.need_resize = False + self.topic_win.resize(1, self.width, 0, 0) + self.form_win.resize(self.height - 3 - Tab.tab_win_height(), + self.width, 1, 0) + self.help_win.resize(1, self.width, self.height - 1, 0) + self.help_win_dyn.resize(1, self.width, + self.height - 2 - Tab.tab_win_height(), 0) + self.lines = [] + + def refresh(self): + if self.need_resize: + self.resize() + self.topic_win.refresh(self._form['title']) + self.refresh_tab_win() + self.help_win.refresh() + self.help_win_dyn.refresh(self.form_win.get_help_message()) + self.form_win.refresh() + diff --git a/poezio/tabs/listtab.py b/poezio/tabs/listtab.py new file mode 100644 index 00000000..4d8bab9c --- /dev/null +++ b/poezio/tabs/listtab.py @@ -0,0 +1,202 @@ +""" +A generic tab that displays a serie of items in a scrollable, searchable, +sortable list. It should be inherited, to actually provide methods that +insert items in the list, and that lets the user interact with them. +""" + +import logging +log = logging.getLogger(__name__) + +import curses +import collections + +import windows +from common import safeJID +from decorators import refresh_wrapper + +from . import Tab + + +class ListTab(Tab): + plugin_commands = {} + plugin_keys = {} + + def __init__(self, name, help_message, header_text, cols): + """Parameters: + name: The name of the tab + help_message: The default help message displayed instead of the + input + header_text: The text displayed on the header line, at the top of + the tab + cols: a tuple of 2-tuples. e.g. (('column1_name', number), + ('column2_name', number)) + """ + Tab.__init__(self) + self.state = 'normal' + self.name = name + columns = collections.OrderedDict() + for col, num in cols: + columns[col] = num + self.list_header = windows.ColumnHeaderWin(list(columns)) + self.listview = windows.ListWin(columns) + self.info_header = windows.MucListInfoWin(header_text) + self.default_help_message = windows.HelpText(help_message) + self.input = self.default_help_message + self.key_func["KEY_DOWN"] = self.move_cursor_down + self.key_func["KEY_UP"] = self.move_cursor_up + self.key_func['^I'] = self.completion + self.key_func["/"] = self.on_slash + self.key_func['KEY_LEFT'] = self.list_header.sel_column_left + self.key_func['KEY_RIGHT'] = self.list_header.sel_column_right + self.key_func[' '] = self.sort_by + self.register_command('close', self.close, + shortdesc='Close this tab.') + self.resize() + self.update_keys() + self.update_commands() + + def get_columns_sizes(self): + """ + Must be implemented in subclasses. Must return a dict like this: + {'column1_name': size1, + 'column2_name': size2} + Where the size are calculated based on the size of the tab etc + """ + raise NotImplementedError + + + def refresh(self): + if self.need_resize: + self.resize() + log.debug(' TAB Refresh: %s', self.__class__.__name__) + if self.size.tab_degrade_y: + display_info_win = False + else: + display_info_win = True + + self.info_header.refresh(window=self.listview) + if display_info_win: + self.info_win.refresh() + self.refresh_tab_win() + self.list_header.refresh() + self.listview.refresh() + self.input.refresh() + + def resize(self): + if self.size.tab_degrade_y: + info_win_height = 0 + tab_win_height = 0 + else: + info_win_height = self.core.information_win_size + tab_win_height = Tab.tab_win_height() + + self.info_header.resize(1, self.width, + self.height - 2 - info_win_height + - tab_win_height, + 0) + column_size = self.get_columns_sizes() + self.list_header.resize_columns(column_size) + self.list_header.resize(1, self.width, 0, 0) + self.listview.resize_columns(column_size) + self.listview.resize(self.height - 3 - info_win_height - tab_win_height, + self.width, 1, 0) + self.input.resize(1, self.width, self.height-1, 0) + + def on_slash(self): + """ + '/' is pressed, activate the input + """ + curses.curs_set(1) + self.input = windows.CommandInput("", self.reset_help_message, self.execute_slash_command) + self.input.resize(1, self.width, self.height-1, 0) + self.input.do_command("/") # we add the slash + + def close(self, arg=None): + self.input.on_delete() + self.core.close_tab(self) + + def set_error(self, msg, code, body): + """ + If there's an error (retrieving the values etc) + """ + self._error_message = 'Error: %(code)s - %(msg)s: %(body)s' % {'msg':msg, 'body':body, 'code':code} + self.info_header.message = self._error_message + self.info_header.refresh() + curses.doupdate() + + def sort_by(self): + if self.list_header.get_order(): + self.listview.sort_by_column( + col_name=self.list_header.get_sel_column(), + asc=False) + self.list_header.set_order(False) + self.list_header.refresh() + else: + self.listview.sort_by_column( + col_name=self.list_header.get_sel_column(), + asc=True) + self.list_header.set_order(True) + self.list_header.refresh() + self.core.doupdate() + + @refresh_wrapper.always + def reset_help_message(self, _=None): + if self.closed: + return True + curses.curs_set(0) + self.input = self.default_help_message + self.input.resize(1, self.width, self.height-1, 0) + return True + + def execute_slash_command(self, txt): + if txt.startswith('/'): + self.input.key_enter() + self.execute_command(txt) + return self.reset_help_message() + + def completion(self): + if isinstance(self.input, windows.Input): + self.complete_commands(self.input) + + def on_input(self, key, raw): + res = self.input.do_command(key, raw=raw) + if res and not isinstance(self.input, windows.Input): + return True + elif res: + return False + if not raw and key in self.key_func: + return self.key_func[key]() + + def on_info_win_size_changed(self): + if self.core.information_win_size >= self.height-3: + return + self.info_header.resize(1, self.width, self.height-2-self.core.information_win_size - Tab.tab_win_height(), 0) + self.listview.resize(self.height-3-self.core.information_win_size - Tab.tab_win_height(), self.width, 1, 0) + + def on_lose_focus(self): + self.state = 'normal' + + def on_gain_focus(self): + self.state = 'current' + curses.curs_set(0) + + def on_scroll_up(self): + return self.listview.scroll_up() + + def on_scroll_down(self): + return self.listview.scroll_down() + + def move_cursor_up(self): + self.listview.move_cursor_up() + self.listview.refresh() + self.core.doupdate() + + def move_cursor_down(self): + self.listview.move_cursor_down() + self.listview.refresh() + self.core.doupdate() + + def matching_names(self): + return [(2, self.name)] + + diff --git a/poezio/tabs/muclisttab.py b/poezio/tabs/muclisttab.py new file mode 100644 index 00000000..92d55190 --- /dev/null +++ b/poezio/tabs/muclisttab.py @@ -0,0 +1,70 @@ +""" +A MucListTab is a tab listing the rooms on a conference server. + +It has no functionnality except scrolling the list, and allowing the +user to join the rooms. +""" +import logging +log = logging.getLogger(__name__) + +from . import ListTab + +from slixmpp.plugins.xep_0030.stanza.items import DiscoItem + +class MucListTab(ListTab): + """ + A tab listing rooms from a specific server, displaying various information, + scrollable, and letting the user join them, etc + """ + plugin_commands = {} + plugin_keys = {} + + def __init__(self, server): + ListTab.__init__(self, server, + "“j”: join room.", + 'Chatroom list on server %s (Loading)' % server, + (('node-part', 0), ('name', 2), ('users', 3))) + self.key_func['j'] = self.join_selected + self.key_func['J'] = self.join_selected_no_focus + self.key_func['^M'] = self.join_selected + + def get_columns_sizes(self): + return {'node-part': int(self.width* 2 / 8), + 'name': int(self.width * 5 / 8), + 'users': self.width - int(self.width * 2 / 8) + - int(self.width * 5 / 8)} + + def join_selected_no_focus(self): + return + + def on_muc_list_item_received(self, iq): + """ + Callback called when a disco#items result is received + Used with command_list + """ + if iq['type'] == 'error': + self.set_error(iq['error']['type'], iq['error']['code'], iq['error']['text']) + return + def get_items(): + substanza = iq['disco_items'] + for item in substanza['substanzas']: + if isinstance(item, DiscoItem): + yield (item['jid'], item['node'], item['name']) + items = [(item[0].split('@')[0], + item[0], + item[2] or '', '') for item in get_items()] + self.listview.set_lines(items) + self.info_header.message = 'Chatroom list on server %s' % self.name + if self.core.current_tab() is self: + self.refresh() + else: + self.state = 'highlight' + self.refresh_tab_win() + self.core.doupdate() + + def join_selected(self): + row = self.listview.get_selected_row() + if not row: + return + self.core.command_join(row[1]) + diff --git a/poezio/tabs/muctab.py b/poezio/tabs/muctab.py new file mode 100644 index 00000000..1f3ec6d8 --- /dev/null +++ b/poezio/tabs/muctab.py @@ -0,0 +1,1720 @@ +""" +Module for the MucTab + +A MucTab is a tab for multi-user chats as defined in XEP-0045. + +It keeps track of many things such as part/joins, maintains an +user list, and updates private tabs when necessary. +""" + +import logging +log = logging.getLogger(__name__) + +import bisect +import curses +import os +import random +import re +from datetime import datetime + +from . import ChatTab, Tab + +import common +import fixes +import multiuserchat as muc +import timed_events +import windows +import xhtml +from common import safeJID +from config import config +from decorators import refresh_wrapper, command_args_parser +from logger import logger +from roster import roster +from theming import get_theme, dump_tuple +from user import User + + +SHOW_NAME = { + 'dnd': 'busy', + 'away': 'away', + 'xa': 'not available', + 'chat': 'chatty', + '': 'available' + } + +NS_MUC_USER = 'http://jabber.org/protocol/muc#user' + + +class MucTab(ChatTab): + """ + The tab containing a multi-user-chat room. + It contains an userlist, an input, a topic, an information and a chat zone + """ + message_type = 'groupchat' + plugin_commands = {} + plugin_keys = {} + def __init__(self, jid, nick, password=None): + self.joined = False + ChatTab.__init__(self, jid) + if self.joined == False: + self._state = 'disconnected' + self.own_nick = nick + self.name = jid + self.password = password + self.users = [] + self.privates = [] # private conversations + self.topic = '' + self.topic_from = '' + self.remote_wants_chatstates = True + # Self ping event, so we can cancel it when we leave the room + self.self_ping_event = None + # We send active, composing and paused states to the MUC because + # the chatstate may or may not be filtered by the MUC, + # that’s not our problem. + self.topic_win = windows.Topic() + self.text_win = windows.TextWin() + self._text_buffer.add_window(self.text_win) + self.v_separator = windows.VerticalSeparator() + self.user_win = windows.UserList() + self.info_header = windows.MucInfoWin() + self.input = windows.MessageInput() + self.ignores = [] # set of Users + # keys + self.key_func['^I'] = self.completion + self.key_func['M-u'] = self.scroll_user_list_down + self.key_func['M-y'] = self.scroll_user_list_up + self.key_func['M-n'] = self.go_to_next_hl + self.key_func['M-p'] = self.go_to_prev_hl + # commands + self.register_command('ignore', self.command_ignore, + usage='<nickname>', + desc='Ignore a specified nickname.', + shortdesc='Ignore someone', + completion=self.completion_ignore) + self.register_command('unignore', self.command_unignore, + usage='<nickname>', + desc='Remove the specified nickname from the ignore list.', + shortdesc='Unignore someone.', + completion=self.completion_unignore) + self.register_command('kick', self.command_kick, + usage='<nick> [reason]', + desc='Kick the user with the specified nickname.' + ' You also can give an optional reason.', + shortdesc='Kick someone.', + completion=self.completion_quoted) + self.register_command('ban', self.command_ban, + usage='<nick> [reason]', + desc='Ban the user with the specified nickname.' + ' You also can give an optional reason.', + shortdesc='Ban someone', + completion=self.completion_quoted) + self.register_command('role', self.command_role, + usage='<nick> <role> [reason]', + desc='Set the role of an user. Roles can be:' + ' none, visitor, participant, moderator.' + ' You also can give an optional reason.', + shortdesc='Set the role of an user.', + completion=self.completion_role) + self.register_command('affiliation', self.command_affiliation, + usage='<nick or jid> <affiliation>', + desc='Set the affiliation of an user. Affiliations can be:' + ' outcast, none, member, admin, owner.', + shortdesc='Set the affiliation of an user.', + completion=self.completion_affiliation) + self.register_command('topic', self.command_topic, + usage='<subject>', + desc='Change the subject of the room.', + shortdesc='Change the subject.', + completion=self.completion_topic) + self.register_command('query', self.command_query, + usage='<nick> [message]', + desc='Open a private conversation with <nick>. This nick' + ' has to be present in the room you\'re currently in.' + ' If you specified a message after the nickname, it ' + 'will immediately be sent to this user.', + shortdesc='Query an user.', + completion=self.completion_quoted) + self.register_command('part', self.command_part, + usage='[message]', + desc='Disconnect from a room. You can' + ' specify an optional message.', + shortdesc='Leave the room.') + self.register_command('close', self.command_close, + usage='[message]', + desc='Disconnect from a room and close the tab.' + ' You can specify an optional message if ' + 'you are still connected.', + shortdesc='Close the tab.') + self.register_command('nick', self.command_nick, + usage='<nickname>', + desc='Change your nickname in the current room.', + shortdesc='Change your nickname.', + completion=self.completion_nick) + self.register_command('recolor', self.command_recolor, + usage='[random]', + desc='Re-assign a color to all participants of the' + ' current room, based on the last time they talked.' + ' Use this if the participants currently talking ' + 'have too many identical colors. Use /recolor random' + ' for a non-deterministic result.', + shortdesc='Change the nicks colors.', + completion=self.completion_recolor) + self.register_command('color', self.command_color, + usage='<nick> <color>', + desc='Fix a color for a nick. Use "unset" instead of a color' + ' to remove the attribution', + shortdesc='Fix a color for a nick.', + completion=self.completion_color) + self.register_command('cycle', self.command_cycle, + usage='[message]', + desc='Leave the current room and rejoin it immediately.', + shortdesc='Leave and re-join the room.') + self.register_command('info', self.command_info, + usage='<nickname>', + desc='Display some information about the user ' + 'in the MUC: its/his/her role, affiliation,' + ' status and status message.', + shortdesc='Show an user\'s infos.', + completion=self.completion_info) + self.register_command('configure', self.command_configure, + desc='Configure the current room, through a form.', + shortdesc='Configure the room.') + self.register_command('version', self.command_version, + usage='<jid or nick>', + desc='Get the software version of the given JID' + ' or nick in room (usually its XMPP client' + ' and Operating System).', + shortdesc='Get the software version of a jid.', + completion=self.completion_version) + self.register_command('names', self.command_names, + desc='Get the users in the room with their roles.', + shortdesc='List the users.') + self.register_command('invite', self.command_invite, + desc='Invite a contact to this room', + usage='<jid> [reason]', + shortdesc='Invite a contact to this room', + completion=self.completion_invite) + + if self.core.xmpp.boundjid.server == "gmail.com": #gmail sucks + del self.commands["nick"] + + self.resize() + self.update_commands() + self.update_keys() + + @property + def general_jid(self): + return self.name + + @property + def is_muc(self): + return True + + @property + def last_connection(self): + last_message = self._text_buffer.last_message + if last_message: + return last_message.time + return None + + @refresh_wrapper.always + def go_to_next_hl(self): + """ + Go to the next HL in the room, or the last + """ + self.text_win.next_highlight() + + @refresh_wrapper.always + def go_to_prev_hl(self): + """ + Go to the previous HL in the room, or the first + """ + self.text_win.previous_highlight() + + def completion_version(self, the_input): + """Completion for /version""" + compare_users = lambda x: x.last_talked + userlist = [] + for user in sorted(self.users, key=compare_users, reverse=True): + if user.nick != self.own_nick: + userlist.append(user.nick) + comp = [] + for jid in (jid for jid in roster.jids() if len(roster[jid])): + for resource in roster[jid].resources: + comp.append(resource.jid) + comp.sort() + userlist.extend(comp) + + return the_input.auto_completion(userlist, quotify=False) + + def completion_info(self, the_input): + """Completion for /info""" + compare_users = lambda x: x.last_talked + userlist = [] + for user in sorted(self.users, key=compare_users, reverse=True): + userlist.append(user.nick) + return the_input.auto_completion(userlist, quotify=False) + + def completion_nick(self, the_input): + """Completion for /nick""" + nicks = [os.environ.get('USER'), + config.get('default_nick'), + self.core.get_bookmark_nickname(self.name)] + nicks = [i for i in nicks if i] + return the_input.auto_completion(nicks, '', quotify=False) + + def completion_recolor(self, the_input): + if the_input.get_argument_position() == 1: + return the_input.new_completion(['random'], 1, '', quotify=False) + return True + + def completion_color(self, the_input): + """Completion for /color""" + n = the_input.get_argument_position(quoted=True) + if n == 1: + userlist = [user.nick for user in self.users] + if self.own_nick in userlist: + userlist.remove(self.own_nick) + return the_input.new_completion(userlist, 1, '', quotify=True) + elif n == 2: + colors = [i for i in xhtml.colors if i] + colors.sort() + colors.append('unset') + colors.append('random') + return the_input.new_completion(colors, 2, '', quotify=False) + + def completion_ignore(self, the_input): + """Completion for /ignore""" + userlist = [user.nick for user in self.users] + if self.own_nick in userlist: + userlist.remove(self.own_nick) + userlist.sort() + return the_input.auto_completion(userlist, quotify=False) + + def completion_role(self, the_input): + """Completion for /role""" + n = the_input.get_argument_position(quoted=True) + if n == 1: + userlist = [user.nick for user in self.users] + if self.own_nick in userlist: + userlist.remove(self.own_nick) + return the_input.new_completion(userlist, 1, '', quotify=True) + elif n == 2: + possible_roles = ['none', 'visitor', 'participant', 'moderator'] + return the_input.new_completion(possible_roles, 2, '', + quotify=True) + + def completion_affiliation(self, the_input): + """Completion for /affiliation""" + n = the_input.get_argument_position(quoted=True) + if n == 1: + userlist = [user.nick for user in self.users] + if self.own_nick in userlist: + userlist.remove(self.own_nick) + jidlist = [user.jid.bare for user in self.users] + if self.core.xmpp.boundjid.bare in jidlist: + jidlist.remove(self.core.xmpp.boundjid.bare) + userlist.extend(jidlist) + return the_input.new_completion(userlist, 1, '', quotify=True) + elif n == 2: + possible_affiliations = ['none', 'member', 'admin', + 'owner', 'outcast'] + return the_input.new_completion(possible_affiliations, 2, '', + quotify=True) + + @command_args_parser.quoted(1, 1, ['']) + def command_invite(self, args): + """/invite <jid> [reason]""" + if args is None: + return self.core.command_help('invite') + jid, reason = args + self.core.command_invite('%s %s "%s"' % (jid, self.name, reason)) + + def completion_invite(self, the_input): + """Completion for /invite""" + n = the_input.get_argument_position(quoted=True) + if n == 1: + return the_input.new_completion(roster.jids(), 1, quotify=True) + + def scroll_user_list_up(self): + self.user_win.scroll_up() + self.user_win.refresh(self.users) + self.input.refresh() + + def scroll_user_list_down(self): + self.user_win.scroll_down() + self.user_win.refresh(self.users) + self.input.refresh() + + @command_args_parser.quoted(1) + def command_info(self, args): + """ + /info <nick> + """ + if args is None: + return self.core.command_help('info') + nick = args[0] + user = self.get_user_by_name(nick) + if not user: + return self.core.information("Unknown user: %s" % nick, "Error") + theme = get_theme() + inf = '\x19' + dump_tuple(theme.COLOR_INFORMATION_TEXT) + '}' + if user.jid: + user_jid = '%s (\x19%s}%s\x19o%s)' % ( + inf, + dump_tuple(theme.COLOR_MUC_JID), + user.jid, + inf) + else: + user_jid = '' + info = ('\x19%s}%s\x19o%s%s: show: \x19%s}%s\x19o%s, affiliation:' + ' \x19%s}%s\x19o%s, role: \x19%s}%s\x19o%s') % ( + dump_tuple(user.color), + nick, + user_jid, + inf, + dump_tuple(theme.color_show(user.show)), + user.show or 'Available', + inf, + dump_tuple(theme.color_role(user.role)), + user.affiliation or 'None', + inf, + dump_tuple(theme.color_role(user.role)), + user.role or 'None', + '\n%s' % user.status if user.status else '') + self.add_message(info, typ=0) + self.core.refresh_window() + + @command_args_parser.quoted(0) + def command_configure(self, ignored): + """ + /configure + """ + def on_form_received(form): + if not form: + self.core.information( + 'Could not retrieve the configuration form', + 'Error') + return + self.core.open_new_form(form, self.cancel_config, self.send_config) + + fixes.get_room_form(self.core.xmpp, self.name, on_form_received) + + def cancel_config(self, form): + """ + The user do not want to send his/her config, send an iq cancel + """ + muc.cancel_config(self.core.xmpp, self.name) + self.core.close_tab() + + def send_config(self, form): + """ + The user sends his/her config to the server + """ + muc.configure_room(self.core.xmpp, self.name, form) + self.core.close_tab() + + @command_args_parser.raw + def command_cycle(self, msg): + """/cycle [reason]""" + self.command_part(msg) + self.disconnect() + self.user_win.pos = 0 + self.core.disable_private_tabs(self.name) + self.join() + + def join(self): + """ + Join the room + """ + status = self.core.get_status() + if self.last_connection: + delta = datetime.now() - self.last_connection + seconds = delta.seconds + delta.days * 24 * 3600 + else: + seconds = 0 + muc.join_groupchat(self.core, self.name, self.own_nick, + self.password, + status=status.message, + show=status.show, + seconds=seconds) + + @command_args_parser.quoted(0, 1, ['']) + def command_recolor(self, args): + """ + /recolor [random] + Re-assign color to the participants of the room + """ + deterministic = config.get_by_tabname('deterministic_nick_colors', self.name) + if deterministic: + for user in self.users: + if user.nick == self.own_nick: + continue + color = self.search_for_color(user.nick) + if color != '': + continue + user.set_deterministic_color() + if args[0] == 'random': + self.core.information('"random" was provided, but poezio is ' + 'configured to use deterministic colors', + 'Warning') + self.user_win.refresh(self.users) + self.input.refresh() + return + compare_users = lambda x: x.last_talked + users = list(self.users) + sorted_users = sorted(users, key=compare_users, reverse=True) + full_sorted_users = sorted_users[:] + # search our own user, to remove it from the list + # Also remove users whose color is fixed + for user in full_sorted_users: + color = self.search_for_color(user.nick) + if user.nick == self.own_nick: + sorted_users.remove(user) + user.color = get_theme().COLOR_OWN_NICK + elif color != '': + sorted_users.remove(user) + user.change_color(color, deterministic) + colors = list(get_theme().LIST_COLOR_NICKNAMES) + if args[0] == 'random': + random.shuffle(colors) + for i, user in enumerate(sorted_users): + user.color = colors[i % len(colors)] + self.text_win.rebuild_everything(self._text_buffer) + self.user_win.refresh(self.users) + self.text_win.refresh() + self.input.refresh() + + @command_args_parser.quoted(2, 2, ['']) + def command_color(self, args): + """ + /color <nick> <color> + Fix a color for a nick. + Use "unset" instead of a color to remove the attribution. + User "random" to attribute a random color. + """ + if args is None: + return self.core.command_help('color') + nick = args[0] + color = args[1].lower() + user = self.get_user_by_name(nick) + if not color in xhtml.colors and color not in ('unset', 'random'): + return self.core.information("Unknown color: %s" % color, 'Error') + if user and user.nick == self.own_nick: + return self.core.information("You cannot change the color of your" + " own nick.", 'Error') + if color == 'unset': + if config.remove_and_save(nick, 'muc_colors'): + self.core.information('Color for nick %s unset' % (nick)) + else: + if color == 'random': + color = random.choice(list(xhtml.colors)) + if user: + user.change_color(color) + config.set_and_save(nick, color, 'muc_colors') + nick_color_aliases = config.get_by_tabname('nick_color_aliases', self.name) + if nick_color_aliases: + # if any user in the room has a nick which is an alias of the + # nick, update its color + for tab in self.core.get_tabs(MucTab): + for u in tab.users: + nick_alias = re.sub('^_*', '', u.nick) + nick_alias = re.sub('_*$', '', nick_alias) + if nick_alias == nick: + u.change_color(color) + self.text_win.rebuild_everything(self._text_buffer) + self.user_win.refresh(self.users) + self.text_win.refresh() + self.input.refresh() + + @command_args_parser.quoted(1) + def command_version(self, args): + """ + /version <jid or nick> + """ + def callback(res): + if not res: + return self.core.information('Could not get the software ' + 'version from %s' % (jid,), + 'Warning') + version = '%s is running %s version %s on %s' % ( + jid, + res.get('name') or 'an unknown software', + res.get('version') or 'unknown', + res.get('os') or 'an unknown platform') + self.core.information(version, 'Info') + if args is None: + return self.core.command_help('version') + nick = args[0] + if nick in [user.nick for user in self.users]: + jid = safeJID(self.name).bare + jid = safeJID(jid + '/' + nick) + else: + jid = safeJID(nick) + fixes.get_version(self.core.xmpp, jid, + callback=callback) + + @command_args_parser.quoted(1) + def command_nick(self, args): + """ + /nick <nickname> + """ + if args is None: + return self.core.command_help('nick') + nick = args[0] + if not self.joined: + return self.core.information('/nick only works in joined rooms', + 'Info') + current_status = self.core.get_status() + if not safeJID(self.name + '/' + nick): + return self.core.information('Invalid nick', 'Info') + muc.change_nick(self.core, self.name, nick, + current_status.message, + current_status.show) + + @command_args_parser.quoted(0, 1, ['']) + def command_part(self, args): + """ + /part [msg] + """ + arg = args[0] + msg = None + if self.joined: + info_col = dump_tuple(get_theme().COLOR_INFORMATION_TEXT) + char_quit = get_theme().CHAR_QUIT + spec_col = dump_tuple(get_theme().COLOR_QUIT_CHAR) + + if config.get_by_tabname('display_user_color_in_join_part', + self.general_jid): + color = dump_tuple(get_theme().COLOR_OWN_NICK) + else: + color = 3 + + if arg: + msg = ('\x19%(color_spec)s}%(spec)s\x19%(info_col)s} ' + 'You (\x19%(color)s}%(nick)s\x19%(info_col)s})' + ' left the chatroom' + ' (\x19o%(reason)s\x19%(info_col)s})') % { + 'info_col': info_col, 'reason': arg, + 'spec': char_quit, 'color': color, + 'color_spec': spec_col, + 'nick': self.own_nick, + } + else: + msg = ('\x19%(color_spec)s}%(spec)s\x19%(info_col)s} ' + 'You (\x19%(color)s}%(nick)s\x19%(info_col)s})' + ' left the chatroom') % { + 'info_col': info_col, + 'spec': char_quit, 'color': color, + 'color_spec': spec_col, + 'nick': self.own_nick, + } + + self.add_message(msg, typ=2) + self.disconnect() + muc.leave_groupchat(self.core.xmpp, self.name, self.own_nick, arg) + self.core.disable_private_tabs(self.name, reason=msg) + if self == self.core.current_tab(): + self.refresh() + self.core.doupdate() + + @command_args_parser.raw + def command_close(self, msg): + """ + /close [msg] + """ + self.command_part(msg) + self.core.close_tab() + + @command_args_parser.quoted(1, 1) + def command_query(self, args): + """ + /query <nick> [message] + """ + if args is None: + return self.core.command_help('query') + nick = args[0] + r = None + for user in self.users: + if user.nick == nick: + r = self.core.open_private_window(self.name, user.nick) + if r and len(args) == 2: + msg = args[1] + self.core.current_tab().command_say( + xhtml.convert_simple_to_full_colors(msg)) + if not r: + self.core.information("Cannot find user: %s" % nick, 'Error') + + @command_args_parser.raw + def command_topic(self, subject): + """ + /topic [new topic] + """ + if not subject: + self._text_buffer.add_message( + "\x19%s}The subject of the room is: %s %s" % + (dump_tuple(get_theme().COLOR_INFORMATION_TEXT), + self.topic, + '(set by %s)' % self.topic_from if self.topic_from + else '')) + self.refresh() + return + + muc.change_subject(self.core.xmpp, self.name, subject) + + @command_args_parser.quoted(0) + def command_names(self, args): + """ + /names + """ + if not self.joined: + return + + aff = { + 'owner': get_theme().CHAR_AFFILIATION_OWNER, + 'admin': get_theme().CHAR_AFFILIATION_ADMIN, + 'member': get_theme().CHAR_AFFILIATION_MEMBER, + 'none': get_theme().CHAR_AFFILIATION_NONE, + } + + colors = {} + colors["visitor"] = dump_tuple(get_theme().COLOR_USER_VISITOR) + colors["moderator"] = dump_tuple(get_theme().COLOR_USER_MODERATOR) + colors["participant"] = dump_tuple(get_theme().COLOR_USER_PARTICIPANT) + color_other = dump_tuple(get_theme().COLOR_USER_NONE) + + buff = ['Users: %s \n' % len(self.users)] + for user in self.users: + affiliation = aff.get(user.affiliation, + get_theme().CHAR_AFFILIATION_NONE) + color = colors.get(user.role, color_other) + buff.append('\x19%s}%s\x19o\x19%s}%s\x19o' % ( + color, affiliation, dump_tuple(user.color), user.nick)) + + buff.append('\n') + message = ' '.join(buff) + + self._text_buffer.add_message(message) + self.text_win.refresh() + self.input.refresh() + + def completion_topic(self, the_input): + if the_input.get_argument_position() == 1: + return the_input.auto_completion([self.topic], '', quotify=False) + + def completion_quoted(self, the_input): + """Nick completion, but with quotes""" + if the_input.get_argument_position(quoted=True) == 1: + compare_users = lambda x: x.last_talked + word_list = [] + for user in sorted(self.users, key=compare_users, reverse=True): + if user.nick != self.own_nick: + word_list.append(user.nick) + + return the_input.new_completion(word_list, 1, quotify=True) + + @command_args_parser.quoted(1, 1) + def command_kick(self, args): + """ + /kick <nick> [reason] + """ + if args is None: + return self.core.command_help('kick') + if len(args) == 2: + msg = ' "%s"' % args[1] + else: + msg = '' + self.command_role('"'+args[0]+ '" none'+msg) + + @command_args_parser.quoted(1, 1) + def command_ban(self, args): + """ + /ban <nick> [reason] + """ + def callback(iq): + if iq['type'] == 'error': + self.core.room_error(iq, self.name) + if args is None: + return self.core.command_help('ban') + if len(args) > 1: + msg = args[1] + else: + msg = '' + nick = args[0] + + if nick in [user.nick for user in self.users]: + res = muc.set_user_affiliation(self.core.xmpp, self.name, + 'outcast', nick=nick, + callback=callback, reason=msg) + else: + res = muc.set_user_affiliation(self.core.xmpp, self.name, + 'outcast', jid=safeJID(nick), + callback=callback, reason=msg) + if not res: + self.core.information('Could not ban user', 'Error') + + @command_args_parser.quoted(2, 1, ['']) + def command_role(self, args): + """ + /role <nick> <role> [reason] + Changes the role of an user + roles can be: none, visitor, participant, moderator + """ + def callback(iq): + if iq['type'] == 'error': + self.core.room_error(iq, self.name) + + if args is None: + return self.core.command_help('role') + + nick, role, reason = args[0], args[1].lower(), args[2] + + valid_roles = ('none', 'visitor', 'participant', 'moderator') + + if not self.joined or role not in valid_roles: + return self.core.information('The role must be one of ' + ', '.join(valid_roles), + 'Error') + + if not safeJID(self.name + '/' + nick): + return self.core.information('Invalid nick', 'Info') + muc.set_user_role(self.core.xmpp, self.name, nick, reason, role, + callback=callback) + + @command_args_parser.quoted(2) + def command_affiliation(self, args): + """ + /affiliation <nick> <role> + Changes the affiliation of an user + affiliations can be: outcast, none, member, admin, owner + """ + def callback(iq): + if iq['type'] == 'error': + self.core.room_error(iq, self.name) + + if args is None: + return self.core.command_help('affiliation') + + nick, affiliation = args[0], args[1].lower() + + if not self.joined: + return + + valid_affiliations = ('outcast', 'none', 'member', 'admin', 'owner') + if affiliation not in valid_affiliations: + return self.core.information('The affiliation must be one of ' + ', '.join(valid_affiliations), + 'Error') + + if nick in [user.nick for user in self.users]: + res = muc.set_user_affiliation(self.core.xmpp, self.name, + affiliation, nick=nick, + callback=callback) + else: + res = muc.set_user_affiliation(self.core.xmpp, self.name, + affiliation, jid=safeJID(nick), + callback=callback) + if not res: + self.core.information('Could not set affiliation', 'Error') + + @command_args_parser.raw + def command_say(self, line, correct=False): + """ + /say <message> + Or normal input + enter + """ + needed = 'inactive' if self.inactive else 'active' + msg = self.core.xmpp.make_message(self.name) + msg['type'] = 'groupchat' + msg['body'] = line + # trigger the event BEFORE looking for colors. + # This lets a plugin insert \x19xxx} colors, that will + # be converted in xhtml. + self.core.events.trigger('muc_say', msg, self) + if not msg['body']: + self.cancel_paused_delay() + self.text_win.refresh() + self.input.refresh() + return + if msg['body'].find('\x19') != -1: + msg.enable('html') + msg['html']['body'] = xhtml.poezio_colors_to_html(msg['body']) + msg['body'] = xhtml.clean_text(msg['body']) + if (config.get_by_tabname('send_chat_states', self.general_jid) + and self.remote_wants_chatstates is not False): + msg['chat_state'] = needed + if correct: + msg['replace']['id'] = self.last_sent_message['id'] + self.cancel_paused_delay() + self.core.events.trigger('muc_say_after', msg, self) + if not msg['body']: + self.cancel_paused_delay() + self.text_win.refresh() + self.input.refresh() + return + self.last_sent_message = msg + msg.send() + self.chat_state = needed + + @command_args_parser.raw + def command_xhtml(self, msg): + message = self.generate_xhtml_message(msg) + if message: + message['type'] = 'groupchat' + message.send() + + @command_args_parser.quoted(1) + def command_ignore(self, args): + """ + /ignore <nick> + """ + if args is None: + return self.core.command_help('ignore') + + nick = args[0] + user = self.get_user_by_name(nick) + if not user: + self.core.information('%s is not in the room' % nick) + elif user in self.ignores: + self.core.information('%s is already ignored' % nick) + else: + self.ignores.append(user) + self.core.information("%s is now ignored" % nick, 'info') + + @command_args_parser.quoted(1) + def command_unignore(self, args): + """ + /unignore <nick> + """ + if args is None: + return self.core.command_help('unignore') + + nick = args[0] + user = self.get_user_by_name(nick) + if not user: + self.core.information('%s is not in the room' % nick) + elif user not in self.ignores: + self.core.information('%s is not ignored' % nick) + else: + self.ignores.remove(user) + self.core.information('%s is now unignored' % nick) + + def completion_unignore(self, the_input): + if the_input.get_argument_position() == 1: + users = [user.nick for user in self.ignores] + return the_input.auto_completion(users, quotify=False) + + def resize(self): + """ + Resize the whole window. i.e. all its sub-windows + """ + self.need_resize = False + if config.get('hide_user_list') or self.size.tab_degrade_x: + display_user_list = False + text_width = self.width + else: + display_user_list = True + text_width = (self.width // 10) * 9 + + if self.size.tab_degrade_y: + display_info_win = False + tab_win_height = 0 + info_win_height = 0 + else: + display_info_win = True + tab_win_height = Tab.tab_win_height() + info_win_height = self.core.information_win_size + + + self.user_win.resize(self.height - 3 - info_win_height + - tab_win_height, + self.width - (self.width // 10) * 9 - 1, + 1, + (self.width // 10) * 9 + 1) + self.v_separator.resize(self.height - 3 - info_win_height - tab_win_height, + 1, 1, 9 * (self.width // 10)) + + self.topic_win.resize(1, self.width, 0, 0) + + self.text_win.resize(self.height - 3 - info_win_height + - tab_win_height, + text_width, 1, 0) + self.text_win.rebuild_everything(self._text_buffer) + self.info_header.resize(1, self.width, + self.height - 2 - info_win_height + - tab_win_height, + 0) + self.input.resize(1, self.width, self.height-1, 0) + + def refresh(self): + if self.need_resize: + self.resize() + log.debug(' TAB Refresh: %s', self.__class__.__name__) + if config.get('hide_user_list') or self.size.tab_degrade_x: + display_user_list = False + else: + display_user_list = True + display_info_win = not self.size.tab_degrade_y + + self.topic_win.refresh(self.get_single_line_topic()) + self.text_win.refresh() + if display_user_list: + self.v_separator.refresh() + self.user_win.refresh(self.users) + self.info_header.refresh(self, self.text_win) + self.refresh_tab_win() + if display_info_win: + self.info_win.refresh() + self.input.refresh() + + def on_input(self, key, raw): + if not raw and key in self.key_func: + self.key_func[key]() + return False + self.input.do_command(key, raw=raw) + empty_after = self.input.get_text() == '' + empty_after = empty_after or (self.input.get_text().startswith('/') + and not + self.input.get_text().startswith('//')) + self.send_composing_chat_state(empty_after) + return False + + def completion(self): + """ + Called when Tab is pressed, complete the nickname in the input + """ + if self.complete_commands(self.input): + return + + # If we are not completing a command or a command argument, + # complete a nick + compare_users = lambda x: x.last_talked + word_list = [] + for user in sorted(self.users, key=compare_users, reverse=True): + if user.nick != self.own_nick: + word_list.append(user.nick) + after = config.get('after_completion') + ' ' + input_pos = self.input.pos + if ' ' not in self.input.get_text()[:input_pos] or ( + self.input.last_completion and + self.input.get_text()[:input_pos] == + self.input.last_completion + after): + add_after = after + else: + if not config.get('add_space_after_completion'): + add_after = '' + else: + add_after = ' ' + self.input.auto_completion(word_list, add_after, quotify=False) + empty_after = self.input.get_text() == '' + empty_after = empty_after or (self.input.get_text().startswith('/') + and not + self.input.get_text().startswith('//')) + self.send_composing_chat_state(empty_after) + + def get_nick(self): + if not config.get('show_muc_jid'): + return safeJID(self.name).user + return self.name + + def get_text_window(self): + return self.text_win + + def on_lose_focus(self): + if self.joined: + if self.input.text: + self.state = 'nonempty' + else: + self.state = 'normal' + else: + self.state = 'disconnected' + self.text_win.remove_line_separator() + self.text_win.add_line_separator(self._text_buffer) + if (config.get_by_tabname('send_chat_states', self.general_jid) and + not self.input.get_text()): + self.send_chat_state('inactive') + self.check_scrolled() + + def on_gain_focus(self): + self.state = 'current' + if (self.text_win.built_lines and self.text_win.built_lines[-1] is None + and not config.get('show_useless_separator')): + self.text_win.remove_line_separator() + curses.curs_set(1) + if self.joined and config.get_by_tabname('send_chat_states', + self.general_jid) and not self.input.get_text(): + self.send_chat_state('active') + + def on_info_win_size_changed(self): + if self.core.information_win_size >= self.height-3: + return + if config.get("hide_user_list"): + text_width = self.width + else: + text_width = (self.width//10)*9 + self.user_win.resize(self.height - 3 - self.core.information_win_size + - Tab.tab_win_height(), + self.width - (self.width // 10) * 9 - 1, + 1, + (self.width // 10) * 9 + 1) + self.v_separator.resize(self.height - 3 - self.core.information_win_size - Tab.tab_win_height(), + 1, 1, 9 * (self.width // 10)) + self.text_win.resize(self.height - 3 - self.core.information_win_size + - Tab.tab_win_height(), + text_width, 1, 0) + self.info_header.resize(1, self.width, + self.height-2-self.core.information_win_size + - Tab.tab_win_height(), + 0) + + def handle_presence(self, presence): + from_nick = presence['from'].resource + from_room = presence['from'].bare + xpath = '{%s}x/{%s}status' % (NS_MUC_USER, NS_MUC_USER) + status_codes = set() + for status_code in presence.findall(xpath): + status_codes.add(status_code.attrib['code']) + + # Check if it's not an error presence. + if presence['type'] == 'error': + return self.core.room_error(presence, from_room) + affiliation = presence['muc']['affiliation'] + show = presence['show'] + status = presence['status'] + role = presence['muc']['role'] + jid = presence['muc']['jid'] + typ = presence['type'] + deterministic = config.get_by_tabname('deterministic_nick_colors', self.name) + color = self.search_for_color(from_nick) + if not self.joined: # user in the room BEFORE us. + # ignore redondant presence message, see bug #1509 + if (from_nick not in [user.nick for user in self.users] + and typ != "unavailable"): + new_user = User(from_nick, affiliation, show, + status, role, jid, deterministic, color) + bisect.insort_left(self.users, new_user) + self.core.events.trigger('muc_join', presence, self) + if '110' in status_codes or self.own_nick == from_nick: + # second part of the condition is a workaround for old + # ejabberd or every gateway in the world that just do + # not send a 110 status code with the presence + self.own_nick = from_nick + self.joined = True + if self.name in self.core.initial_joins: + self.core.initial_joins.remove(self.name) + self._state = 'normal' + elif self != self.core.current_tab(): + self._state = 'joined' + if (self.core.current_tab() is self + and self.core.status.show not in ('xa', 'away')): + self.send_chat_state('active') + new_user.color = get_theme().COLOR_OWN_NICK + + if config.get_by_tabname('display_user_color_in_join_part', + self.general_jid): + color = dump_tuple(new_user.color) + else: + color = 3 + + info_col = dump_tuple(get_theme().COLOR_INFORMATION_TEXT) + warn_col = dump_tuple(get_theme().COLOR_WARNING_TEXT) + spec_col = dump_tuple(get_theme().COLOR_JOIN_CHAR) + + self.add_message( + '\x19%(color_spec)s}%(spec)s\x19%(info_col)s} You ' + '(\x19%(nick_col)s}%(nick)s\x19%(info_col)s}) joined' + ' the chatroom' % + { + 'nick': from_nick, + 'spec': get_theme().CHAR_JOIN, + 'color_spec': spec_col, + 'nick_col': color, + 'info_col': info_col, + }, + typ=2) + if '201' in status_codes: + self.add_message( + '\x19%(info_col)s}Info: The room ' + 'has been created' % + {'info_col': info_col}, + typ=0) + if '170' in status_codes: + self.add_message( + '\x19%(warn_col)s}Warning:\x19%(info_col)s}' + ' This room is publicly logged' % + {'info_col': info_col, + 'warn_col': warn_col}, + typ=0) + if '100' in status_codes: + self.add_message( + '\x19%(warn_col)s}Warning:\x19%(info_col)s}' + ' This room is not anonymous.' % + {'info_col': info_col, + 'warn_col': warn_col}, + typ=0) + if self.core.current_tab() is not self: + self.refresh_tab_win() + self.core.current_tab().input.refresh() + self.core.doupdate() + self.core.enable_private_tabs(self.name) + # Enable the self ping event, to regularly check if we + # are still in the room. + self.enable_self_ping_event() + else: + change_nick = '303' in status_codes + kick = '307' in status_codes and typ == 'unavailable' + ban = '301' in status_codes and typ == 'unavailable' + shutdown = '332' in status_codes and typ == 'unavailable' + non_member = '322' in status_codes and typ == 'unavailable' + user = self.get_user_by_name(from_nick) + # New user + if not user: + self.core.events.trigger('muc_join', presence, self) + self.on_user_join(from_nick, affiliation, show, status, role, + jid, color) + # nick change + elif change_nick: + self.core.events.trigger('muc_nickchange', presence, self) + self.on_user_nick_change(presence, user, from_nick, from_room) + elif ban: + self.core.events.trigger('muc_ban', presence, self) + self.core.on_user_left_private_conversation(from_room, + from_nick, status) + self.on_user_banned(presence, user, from_nick) + # kick + elif kick: + self.core.events.trigger('muc_kick', presence, self) + self.core.on_user_left_private_conversation(from_room, + from_nick, status) + self.on_user_kicked(presence, user, from_nick) + elif shutdown: + self.core.events.trigger('muc_shutdown', presence, self) + self.on_muc_shutdown() + elif non_member: + self.core.events.trigger('muc_shutdown', presence, self) + self.on_non_member_kicked() + # user quit + elif typ == 'unavailable': + self.on_user_leave_groupchat(user, jid, status, + from_nick, from_room) + # status change + else: + self.on_user_change_status(user, from_nick, from_room, + affiliation, role, show, status) + if self.core.current_tab() is self: + self.text_win.refresh() + self.user_win.refresh_if_changed(self.users) + self.info_header.refresh(self, self.text_win) + self.input.refresh() + self.core.doupdate() + + def on_non_member_kicked(self): + """We have been kicked because the MUC is members-only""" + self.add_message( + '\x19%(info_col)s}You have been kicked because you ' + 'are not a member and the room is now members-only.' % { + 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)}, + typ=2) + self.disconnect() + + def on_muc_shutdown(self): + """We have been kicked because the MUC service is shutting down""" + self.add_message( + '\x19%(info_col)s}You have been kicked because the' + ' MUC service is shutting down.' % { + 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)}, + typ=2) + self.disconnect() + + def on_user_join(self, from_nick, affiliation, show, status, role, jid, color): + """ + When a new user joins the groupchat + """ + deterministic = config.get_by_tabname('deterministic_nick_colors', self.name) + user = User(from_nick, affiliation, + show, status, role, jid, deterministic, color) + bisect.insort_left(self.users, user) + hide_exit_join = config.get_by_tabname('hide_exit_join', + self.general_jid) + if hide_exit_join != 0: + if config.get_by_tabname('display_user_color_in_join_part', + self.general_jid): + color = dump_tuple(user.color) + else: + color = 3 + info_col = dump_tuple(get_theme().COLOR_INFORMATION_TEXT) + spec_col = dump_tuple(get_theme().COLOR_JOIN_CHAR) + char_join = get_theme().CHAR_JOIN + if not jid.full: + msg = ('\x19%(color_spec)s}%(spec)s \x19%(color)s}%(nick)s' + '\x19%(info_col)s} joined the chatroom') % { + 'nick': from_nick, 'spec': char_join, + 'color': color, + 'info_col': info_col, + 'color_spec': spec_col, + } + else: + msg = ('\x19%(color_spec)s}%(spec)s \x19%(color)s}%(nick)s' + '\x19%(info_col)s} (\x19%(jid_color)s}%(jid)s\x19' + '%(info_col)s}) joined the chatroom') % { + 'spec': char_join, 'nick': from_nick, + 'color':color, 'jid':jid.full, + 'info_col': info_col, + 'jid_color': dump_tuple(get_theme().COLOR_MUC_JID), + 'color_spec': spec_col, + } + self.add_message(msg, typ=2) + self.core.on_user_rejoined_private_conversation(self.name, from_nick) + + def on_user_nick_change(self, presence, user, from_nick, from_room): + new_nick = presence.find('{%s}x/{%s}item' % (NS_MUC_USER, NS_MUC_USER) + ).attrib['nick'] + if user.nick == self.own_nick: + self.own_nick = new_nick + # also change our nick in all private discussions of this room + self.core.on_muc_own_nickchange(self) + else: + color = config.get_by_tabname(new_nick, 'muc_colors') + if color != '': + deterministic = config.get_by_tabname('deterministic_nick_colors', + self.name) + user.change_color(color, deterministic) + user.change_nick(new_nick) + self.users.remove(user) + bisect.insort_left(self.users, user) + + if config.get_by_tabname('display_user_color_in_join_part', + self.general_jid): + color = dump_tuple(user.color) + else: + color = 3 + info_col = dump_tuple(get_theme().COLOR_INFORMATION_TEXT) + self.add_message('\x19%(color)s}%(old)s\x19%(info_col)s} is' + ' now known as \x19%(color)s}%(new)s' % { + 'old':from_nick, 'new':new_nick, + 'color':color, 'info_col': info_col}, + typ=2) + # rename the private tabs if needed + self.core.rename_private_tabs(self.name, from_nick, new_nick) + + def on_user_banned(self, presence, user, from_nick): + """ + When someone is banned from a muc + """ + self.users.remove(user) + by = presence.find('{%s}x/{%s}item/{%s}actor' % + (NS_MUC_USER, NS_MUC_USER, NS_MUC_USER)) + reason = presence.find('{%s}x/{%s}item/{%s}reason' % + (NS_MUC_USER, NS_MUC_USER, NS_MUC_USER)) + by = by.attrib['jid'] if by is not None else None + + info_col = dump_tuple(get_theme().COLOR_INFORMATION_TEXT) + char_kick = get_theme().CHAR_KICK + + if from_nick == self.own_nick: # we are banned + if by: + kick_msg = ('\x191}%(spec)s \x193}You\x19%(info_col)s}' + ' have been banned by \x194}%(by)s') % { + 'spec': char_kick, 'by': by, + 'info_col': info_col} + else: + kick_msg = ('\x191}%(spec)s \x193}You\x19' + '%(info_col)s} have been banned.') % { + 'spec': char_kick, 'info_col': info_col} + self.core.disable_private_tabs(self.name, reason=kick_msg) + self.disconnect() + self.refresh_tab_win() + self.core.current_tab().input.refresh() + self.core.doupdate() + if config.get_by_tabname('autorejoin', self.general_jid): + delay = config.get_by_tabname('autorejoin_delay', + self.general_jid) + delay = common.parse_str_to_secs(delay) + if delay <= 0: + muc.join_groupchat(self.core, self.name, self.own_nick) + else: + self.core.add_timed_event(timed_events.DelayedEvent( + delay, + muc.join_groupchat, + self.core, + self.name, + self.own_nick)) + + else: + if config.get_by_tabname('display_user_color_in_join_part', + self.general_jid): + color = dump_tuple(user.color) + else: + color = 3 + + if by: + kick_msg = ('\x191}%(spec)s \x19%(color)s}' + '%(nick)s\x19%(info_col)s} ' + 'has been banned by \x194}%(by)s') % { + 'spec': char_kick, 'nick': from_nick, + 'color': color, 'by': by, + 'info_col': info_col} + else: + kick_msg = ('\x191}%(spec)s \x19%(color)s}%(nick)s' + '\x19%(info_col)s} has been banned') % { + 'spec': char_kick, 'nick': from_nick, + 'color': color, 'info_col': info_col} + if reason is not None and reason.text: + kick_msg += ('\x19%(info_col)s} Reason: \x196}' + '%(reason)s\x19%(info_col)s}') % { + 'reason': reason.text, 'info_col': info_col} + self.add_message(kick_msg, typ=2) + + def on_user_kicked(self, presence, user, from_nick): + """ + When someone is kicked from a muc + """ + self.users.remove(user) + actor_elem = presence.find('{%s}x/{%s}item/{%s}actor' % + (NS_MUC_USER, NS_MUC_USER, NS_MUC_USER)) + reason = presence.find('{%s}x/{%s}item/{%s}reason' % + (NS_MUC_USER, NS_MUC_USER, NS_MUC_USER)) + by = None + info_col = dump_tuple(get_theme().COLOR_INFORMATION_TEXT) + char_kick = get_theme().CHAR_KICK + if actor_elem is not None: + by = actor_elem.get('nick') or actor_elem.get('jid') + if from_nick == self.own_nick: # we are kicked + if by: + kick_msg = ('\x191}%(spec)s \x193}You\x19' + '%(info_col)s} have been kicked' + ' by \x193}%(by)s') % { + 'spec': char_kick, 'by': by, + 'info_col': info_col} + else: + kick_msg = ('\x191}%(spec)s \x193}You\x19%(info_col)s}' + ' have been kicked.') % { + 'spec': char_kick, + 'info_col': info_col} + self.core.disable_private_tabs(self.name, reason=kick_msg) + self.disconnect() + self.refresh_tab_win() + self.core.current_tab().input.refresh() + self.core.doupdate() + # try to auto-rejoin + if config.get_by_tabname('autorejoin', self.general_jid): + delay = config.get_by_tabname('autorejoin_delay', + self.general_jid) + delay = common.parse_str_to_secs(delay) + if delay <= 0: + muc.join_groupchat(self.core, self.name, self.own_nick) + else: + self.core.add_timed_event(timed_events.DelayedEvent( + delay, + muc.join_groupchat, + self.core, + self.name, + self.own_nick)) + else: + if config.get_by_tabname('display_user_color_in_join_part', + self.general_jid): + color = dump_tuple(user.color) + else: + color = 3 + if by: + kick_msg = ('\x191}%(spec)s \x19%(color)s}%(nick)s' + '\x19%(info_col)s} has been kicked by ' + '\x193}%(by)s') % { + 'spec': char_kick, 'nick':from_nick, + 'color':color, 'by':by, 'info_col': info_col} + else: + kick_msg = ('\x191}%(spec)s \x19%(color)s}%(nick)s' + '\x19%(info_col)s} has been kicked') % { + 'spec': char_kick, 'nick': from_nick, + 'color':color, 'info_col': info_col} + if reason is not None and reason.text: + kick_msg += ('\x19%(info_col)s} Reason: \x196}' + '%(reason)s') % { + 'reason': reason.text, 'info_col': info_col} + self.add_message(kick_msg, typ=2) + + def on_user_leave_groupchat(self, user, jid, status, from_nick, from_room): + """ + When an user leaves a groupchat + """ + self.users.remove(user) + if self.own_nick == user.nick: + # We are now out of the room. + # Happens with some buggy (? not sure) servers + self.disconnect() + self.core.disable_private_tabs(from_room) + self.refresh_tab_win() + + hide_exit_join = config.get_by_tabname('hide_exit_join', + self.general_jid) + + if hide_exit_join <= -1 or user.has_talked_since(hide_exit_join): + if config.get_by_tabname('display_user_color_in_join_part', + self.general_jid): + color = dump_tuple(user.color) + else: + color = 3 + info_col = dump_tuple(get_theme().COLOR_INFORMATION_TEXT) + spec_col = dump_tuple(get_theme().COLOR_QUIT_CHAR) + + if not jid.full: + leave_msg = ('\x19%(color_spec)s}%(spec)s \x19%(color)s}' + '%(nick)s\x19%(info_col)s} has left the ' + 'chatroom') % { + 'nick':from_nick, 'color':color, + 'spec':get_theme().CHAR_QUIT, + 'info_col': info_col, + 'color_spec': spec_col} + else: + jid_col = dump_tuple(get_theme().COLOR_MUC_JID) + leave_msg = ('\x19%(color_spec)s}%(spec)s \x19%(color)s}' + '%(nick)s\x19%(info_col)s} (\x19%(jid_col)s}' + '%(jid)s\x19%(info_col)s}) has left the ' + 'chatroom') % { + 'spec':get_theme().CHAR_QUIT, + 'nick':from_nick, 'color':color, + 'jid':jid.full, 'info_col': info_col, + 'color_spec': spec_col, + 'jid_col': jid_col} + if status: + leave_msg += ' (\x19o%s\x19%s})' % (status, info_col) + self.add_message(leave_msg, typ=2) + self.core.on_user_left_private_conversation(from_room, from_nick, + status) + + def on_user_change_status( + self, user, from_nick, from_room, affiliation, role, show, status): + """ + When an user changes her status + """ + # build the message + display_message = False # flag to know if something significant enough + # to be displayed has changed + if config.get_by_tabname('display_user_color_in_join_part', + self.general_jid): + color = dump_tuple(user.color) + else: + color = 3 + if from_nick == self.own_nick: + msg = '\x19%(color)s}You\x19%(info_col)s} changed: ' % { + 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT), + 'color': color} + else: + msg = '\x19%(color)s}%(nick)s\x19%(info_col)s} changed: ' % { + 'nick': from_nick, 'color': color, + 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)} + if affiliation != user.affiliation: + msg += 'affiliation: %s, ' % affiliation + display_message = True + if role != user.role: + msg += 'role: %s, ' % role + display_message = True + if show != user.show and show in SHOW_NAME: + msg += 'show: %s, ' % SHOW_NAME[show] + display_message = True + if status != user.status: + # if the user sets his status to nothing + if status: + msg += 'status: %s, ' % status + display_message = True + elif show in SHOW_NAME and show == user.show: + msg += 'show: %s, ' % SHOW_NAME[show] + display_message = True + if not display_message: + return + msg = msg[:-2] # remove the last ", " + hide_status_change = config.get_by_tabname('hide_status_change', + self.general_jid) + if hide_status_change < -1: + hide_status_change = -1 + if ((hide_status_change == -1 or \ + user.has_talked_since(hide_status_change) or\ + user.nick == self.own_nick)\ + and\ + (affiliation != user.affiliation or\ + role != user.role or\ + show != user.show or\ + status != user.status))\ + or\ + (affiliation != user.affiliation or\ + role != user.role): + # display the message in the room + self._text_buffer.add_message(msg) + self.core.on_user_changed_status_in_private('%s/%s' % + (from_room, from_nick), + msg) + self.users.remove(user) + # finally, effectively change the user status + user.update(affiliation, show, status, role) + bisect.insort_left(self.users, user) + + def disconnect(self): + """ + Set the state of the room as not joined, so + we can know if we can join it, send messages to it, etc + """ + self.users = [] + if self is not self.core.current_tab(): + self.state = 'disconnected' + self.joined = False + self.disable_self_ping_event() + + def get_single_line_topic(self): + """ + Return the topic as a single-line string (for the window header) + """ + return self.topic.replace('\n', '|') + + def log_message(self, txt, nickname, time=None, typ=1): + """ + Log the messages in the archives, if it needs + to be + """ + if time is None and self.joined: # don't log the history messages + if not logger.log_message(self.name, nickname, txt, typ=typ): + self.core.information('Unable to write in the log file', + 'Error') + + def do_highlight(self, txt, time, nickname): + """ + Set the tab color and returns the nick color + """ + highlighted = False + if not time and nickname and nickname != self.own_nick and self.joined: + + if re.search(r'\b' + self.own_nick.lower() + r'\b', txt.lower()): + if self.state != 'current': + self.state = 'highlight' + highlighted = True + else: + highlight_words = config.get_by_tabname('highlight_on', + self.general_jid) + highlight_words = highlight_words.split(':') + for word in highlight_words: + if word and word.lower() in txt.lower(): + if self.state != 'current': + self.state = 'highlight' + highlighted = True + break + if highlighted: + beep_on = config.get('beep_on').split() + if 'highlight' in beep_on and 'message' not in beep_on: + if not config.get_by_tabname('disable_beep', self.name): + curses.beep() + return highlighted + + def get_user_by_name(self, nick): + """ + Gets the user associated with the given nick, or None if not found + """ + for user in self.users: + if user.nick == nick: + return user + return None + + def add_message(self, txt, time=None, nickname=None, **kwargs): + """ + Note that user can be None even if nickname is not None. It happens + when we receive an history message said by someone who is not + in the room anymore + Return True if the message highlighted us. False otherwise. + """ + + # reset self-ping interval + if self.self_ping_event: + self.enable_self_ping_event() + + self.log_message(txt, nickname, time=time, typ=kwargs.get('typ', 1)) + args = dict() + for key, value in kwargs.items(): + if key not in ('typ', 'forced_user'): + args[key] = value + if nickname is not None: + user = self.get_user_by_name(nickname) + else: + user = None + + if user: + user.set_last_talked(datetime.now()) + args['user'] = user + if not user and kwargs.get('forced_user'): + args['user'] = kwargs['forced_user'] + + if (not time and nickname and nickname != self.own_nick + and self.state != 'current'): + if (self.state != 'highlight' and + config.get_by_tabname('notify_messages', self.name)): + self.state = 'message' + if time and not txt.startswith('/me'): + txt = '\x19%(info_col)s}%(txt)s' % { + 'txt': txt, + 'info_col': dump_tuple(get_theme().COLOR_LOG_MSG)} + elif not nickname: + txt = '\x19%(info_col)s}%(txt)s' % { + 'txt': txt, + 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)} + elif not kwargs.get('highlight'): # TODO + args['highlight'] = self.do_highlight(txt, time, nickname) + time = time or datetime.now() + self._text_buffer.add_message(txt, time, nickname, **args) + return args.get('highlight', False) + + def modify_message(self, txt, old_id, new_id, + time=None, nickname=None, user=None, jid=None): + self.log_message(txt, nickname, time=time, typ=1) + highlight = self.do_highlight(txt, time, nickname) + message = self._text_buffer.modify_message(txt, old_id, new_id, + highlight=highlight, + time=time, user=user, + jid=jid) + if message: + self.text_win.modify_message(old_id, message) + return highlight + return False + + def matching_names(self): + return [(1, safeJID(self.name).user), (3, self.name)] + + def enable_self_ping_event(self): + delay = config.get_by_tabname("self_ping_delay", self.general_jid, default=0) + if delay <= 0: # use 0 or some negative value to disable it + return + self.disable_self_ping_event() + self.self_ping_event = timed_events.DelayedEvent(delay, self.send_self_ping) + self.core.add_timed_event(self.self_ping_event) + + def disable_self_ping_event(self): + if self.self_ping_event is not None: + self.core.remove_timed_event(self.self_ping_event) + self.self_ping_event = None + + def send_self_ping(self): + to = self.name + "/" + self.own_nick + self.core.xmpp.plugin['xep_0199'].send_ping(jid=to, + callback=self.on_self_ping_result, + timeout_callback=self.on_self_ping_failed, + timeout=60) + + def on_self_ping_result(self, iq): + if iq["type"] == "error": + self.command_cycle(iq["error"]["text"] or "not in this room") + self.core.refresh_window() + else: # Re-send a self-ping in a few seconds + self.enable_self_ping_event() + + def search_for_color(self, nick): + """ + Search for the color of a nick in the config file. + Also, look at the colors of its possible aliases if nick_color_aliases + is set. + """ + color = config.get_by_tabname(nick, 'muc_colors') + if color != '': + return color + nick_color_aliases = config.get_by_tabname('nick_color_aliases', self.name) + if nick_color_aliases: + nick_alias = re.sub('^_*(.*?)_*$', '\\1', nick) + color = config.get_by_tabname(nick_alias, 'muc_colors') + return color + + def on_self_ping_failed(self, iq): + self.command_cycle("the MUC server is not responding") + self.core.refresh_window() diff --git a/poezio/tabs/privatetab.py b/poezio/tabs/privatetab.py new file mode 100644 index 00000000..a715a922 --- /dev/null +++ b/poezio/tabs/privatetab.py @@ -0,0 +1,362 @@ +""" +Module for the PrivateTab + +A PrivateTab is a private conversation opened with someone from a MUC +(see muctab.py). The conversation happens with both JID being relative +to the MUC (room@server/nick1 and room@server/nick2). + +This tab references his parent room, and is modified to keep track of +both participant’s nicks. It also has slightly different features than +the ConversationTab (such as tab-completion on nicks from the room). + +""" +import logging +log = logging.getLogger(__name__) + +import curses + +from . import OneToOneTab, MucTab, Tab + +import fixes +import windows +import xhtml +from common import safeJID +from config import config +from decorators import refresh_wrapper +from logger import logger +from theming import get_theme, dump_tuple +from decorators import command_args_parser + +class PrivateTab(OneToOneTab): + """ + The tab containg a private conversation (someone from a MUC) + """ + message_type = 'chat' + plugin_commands = {} + additional_informations = {} + plugin_keys = {} + def __init__(self, name, nick): + OneToOneTab.__init__(self, name) + self.own_nick = nick + self.name = name + self.text_win = windows.TextWin() + self._text_buffer.add_window(self.text_win) + self.info_header = windows.PrivateInfoWin() + self.input = windows.MessageInput() + # keys + self.key_func['^I'] = self.completion + # commands + self.register_command('info', self.command_info, + desc='Display some information about the user in the MUC: its/his/her role, affiliation, status and status message.', + shortdesc='Info about the user.') + self.register_command('unquery', self.command_unquery, + shortdesc='Close the tab.') + self.register_command('close', self.command_unquery, + shortdesc='Close the tab.') + self.register_command('version', self.command_version, + desc='Get the software version of the current interlocutor (usually its XMPP client and Operating System).', + shortdesc='Get the software version of a jid.') + self.resize() + self.parent_muc = self.core.get_tab_by_name(safeJID(name).bare, MucTab) + self.on = True + self.update_commands() + self.update_keys() + + @property + def general_jid(self): + return self.name + + def get_dest_jid(self): + return self.name + + @property + def nick(self): + return self.get_nick() + + @staticmethod + def add_information_element(plugin_name, callback): + """ + Lets a plugin add its own information to the PrivateInfoWin + """ + PrivateTab.additional_informations[plugin_name] = callback + + @staticmethod + def remove_information_element(plugin_name): + del PrivateTab.additional_informations[plugin_name] + + def load_logs(self, log_nb): + logs = logger.get_logs(safeJID(self.name).full.replace('/', '\\'), log_nb) + return logs + + def log_message(self, txt, nickname, time=None, typ=1): + """ + Log the messages in the archives. + """ + if not logger.log_message(self.name, nickname, txt, date=time, typ=typ): + self.core.information('Unable to write in the log file', 'Error') + + def on_close(self): + self.parent_muc.privates.remove(self) + + def completion(self): + """ + Called when Tab is pressed, complete the nickname in the input + """ + if self.complete_commands(self.input): + return + + # If we are not completing a command or a command's argument, complete a nick + compare_users = lambda x: x.last_talked + word_list = [user.nick for user in sorted(self.parent_muc.users, key=compare_users, reverse=True)\ + if user.nick != self.own_nick] + after = config.get('after_completion') + ' ' + input_pos = self.input.pos + if ' ' not in self.input.get_text()[:input_pos] or (self.input.last_completion and\ + self.input.get_text()[:input_pos] == self.input.last_completion + after): + add_after = after + else: + add_after = '' + self.input.auto_completion(word_list, add_after, quotify=False) + empty_after = self.input.get_text() == '' or (self.input.get_text().startswith('/') and not self.input.get_text().startswith('//')) + self.send_composing_chat_state(empty_after) + + @command_args_parser.raw + def command_say(self, line, attention=False, correct=False): + if not self.on: + return + msg = self.core.xmpp.make_message(self.name) + msg['type'] = 'chat' + msg['body'] = line + # trigger the event BEFORE looking for colors. + # This lets a plugin insert \x19xxx} colors, that will + # be converted in xhtml. + self.core.events.trigger('private_say', msg, self) + if not msg['body']: + self.cancel_paused_delay() + self.text_win.refresh() + self.input.refresh() + return + user = self.parent_muc.get_user_by_name(self.own_nick) + replaced = False + if correct or msg['replace']['id']: + msg['replace']['id'] = self.last_sent_message['id'] + if config.get_by_tabname('group_corrections', self.name): + try: + self.modify_message(msg['body'], self.last_sent_message['id'], msg['id'], + user=user, jid=self.core.xmpp.boundjid, nickname=self.own_nick) + replaced = True + except: + log.error('Unable to correct a message', exc_info=True) + else: + del msg['replace'] + + if msg['body'].find('\x19') != -1: + msg.enable('html') + msg['html']['body'] = xhtml.poezio_colors_to_html(msg['body']) + msg['body'] = xhtml.clean_text(msg['body']) + if (config.get_by_tabname('send_chat_states', self.general_jid) and + self.remote_wants_chatstates is not False): + needed = 'inactive' if self.inactive else 'active' + msg['chat_state'] = needed + if attention and self.remote_supports_attention: + msg['attention'] = True + self.core.events.trigger('private_say_after', msg, self) + if not msg['body']: + self.cancel_paused_delay() + self.text_win.refresh() + self.input.refresh() + return + if not replaced: + self.add_message(msg['body'], + nickname=self.own_nick or self.core.own_nick, + forced_user=user, + nick_color=get_theme().COLOR_OWN_NICK, + identifier=msg['id'], + jid=self.core.xmpp.boundjid, + typ=1) + + self.last_sent_message = msg + if self.remote_supports_receipts: + msg._add_receipt = True + msg.send() + self.cancel_paused_delay() + self.text_win.refresh() + self.input.refresh() + + @command_args_parser.ignored + def command_unquery(self): + """ + /unquery + """ + self.core.close_tab() + + @command_args_parser.quoted(0, 1) + def command_version(self, args): + """ + /version + """ + def callback(res): + if not res: + return self.core.information('Could not get the software version from %s' % (jid,), 'Warning') + version = '%s is running %s version %s on %s' % (jid, + res.get('name') or 'an unknown software', + res.get('version') or 'unknown', + res.get('os') or 'an unknown platform') + self.core.information(version, 'Info') + if args: + return self.core.command_version(args[0]) + jid = safeJID(self.name) + fixes.get_version(self.core.xmpp, jid, + callback=callback) + + @command_args_parser.quoted(0, 1) + def command_info(self, arg): + """ + /info + """ + if arg and arg[0]: + self.parent_muc.command_info(arg[0]) + else: + user = safeJID(self.name).resource + self.parent_muc.command_info(user) + + def resize(self): + self.need_resize = False + + if self.size.tab_degrade_y: + info_win_height = 0 + tab_win_height = 0 + else: + info_win_height = self.core.information_win_size + tab_win_height = Tab.tab_win_height() + + self.text_win.resize(self.height - 2 - info_win_height - tab_win_height, + self.width, 0, 0) + self.text_win.rebuild_everything(self._text_buffer) + self.info_header.resize(1, self.width, + self.height - 2 - info_win_height + - tab_win_height, + 0) + self.input.resize(1, self.width, self.height-1, 0) + + def refresh(self): + if self.need_resize: + self.resize() + log.debug(' TAB Refresh: %s', self.__class__.__name__) + display_info_win = not self.size.tab_degrade_y + + self.text_win.refresh() + self.info_header.refresh(self.name, self.text_win, self.chatstate, + PrivateTab.additional_informations) + if display_info_win: + self.info_win.refresh() + + self.refresh_tab_win() + self.input.refresh() + + def refresh_info_header(self): + self.info_header.refresh(self.name, self.text_win, self.chatstate, PrivateTab.additional_informations) + self.input.refresh() + + def get_nick(self): + return safeJID(self.name).resource + + def on_input(self, key, raw): + if not raw and key in self.key_func: + self.key_func[key]() + return False + self.input.do_command(key, raw=raw) + if not self.on: + return False + empty_after = self.input.get_text() == '' or (self.input.get_text().startswith('/') and not self.input.get_text().startswith('//')) + tab = self.core.get_tab_by_name(safeJID(self.name).bare, MucTab) + if tab and tab.joined: + self.send_composing_chat_state(empty_after) + return False + + def on_lose_focus(self): + if self.input.text: + self.state = 'nonempty' + else: + self.state = 'normal' + + self.text_win.remove_line_separator() + self.text_win.add_line_separator(self._text_buffer) + tab = self.core.get_tab_by_name(safeJID(self.name).bare, MucTab) + if tab and tab.joined and config.get_by_tabname('send_chat_states', + self.general_jid) and not self.input.get_text() and self.on: + self.send_chat_state('inactive') + self.check_scrolled() + + def on_gain_focus(self): + self.state = 'current' + curses.curs_set(1) + tab = self.core.get_tab_by_name(safeJID(self.name).bare, MucTab) + if tab and tab.joined and config.get_by_tabname('send_chat_states', + self.general_jid,) and not self.input.get_text() and self.on: + self.send_chat_state('active') + + def on_info_win_size_changed(self): + if self.core.information_win_size >= self.height-3: + return + self.text_win.resize(self.height-2-self.core.information_win_size - Tab.tab_win_height(), self.width, 0, 0) + self.info_header.resize(1, self.width, self.height-2-self.core.information_win_size - Tab.tab_win_height(), 0) + + def get_text_window(self): + return self.text_win + + @refresh_wrapper.conditional + def rename_user(self, old_nick, new_nick): + """ + The user changed her nick in the corresponding muc: update the tab’s name and + display a message. + """ + self.add_message('\x193}%(old)s\x19%(info_col)s} is now known as \x193}%(new)s' % {'old':old_nick, 'new':new_nick, 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)}, typ=2) + new_jid = safeJID(self.name).bare+'/'+new_nick + self.name = new_jid + return self.core.current_tab() is self + + @refresh_wrapper.conditional + def user_left(self, status_message, from_nick): + """ + The user left the associated MUC + """ + self.deactivate() + if not status_message: + self.add_message('\x191}%(spec)s \x193}%(nick)s\x19%(info_col)s} has left the room' % {'nick':from_nick, 'spec':get_theme().CHAR_QUIT, 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)}, typ=2) + else: + self.add_message('\x191}%(spec)s \x193}%(nick)s\x19%(info_col)s} has left the room (%(status)s)"' % {'nick':from_nick, 'spec':get_theme().CHAR_QUIT, 'status': status_message, 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)}, typ=2) + return self.core.current_tab() is self + + @refresh_wrapper.conditional + def user_rejoined(self, nick): + """ + The user (or at least someone with the same nick) came back in the MUC + """ + self.activate() + self.check_features() + tab = self.core.get_tab_by_name(safeJID(self.name).bare, MucTab) + color = 3 + if tab and config.get_by_tabname('display_user_color_in_join_part', + self.general_jid): + user = tab.get_user_by_name(nick) + if user: + color = dump_tuple(user.color) + self.add_message('\x194}%(spec)s \x19%(color)s}%(nick)s\x19%(info_col)s} joined the room' % {'nick':nick, 'color': color, 'spec':get_theme().CHAR_JOIN, 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)}, typ=2) + return self.core.current_tab() is self + + def activate(self, reason=None): + self.on = True + if reason: + self.add_message(txt=reason, typ=2) + + def deactivate(self, reason=None): + self.on = False + self.remote_wants_chatstates = None + if reason: + self.add_message(txt=reason, typ=2) + + def matching_names(self): + return [(3, safeJID(self.name).resource), (4, self.name)] + + diff --git a/poezio/tabs/rostertab.py b/poezio/tabs/rostertab.py new file mode 100644 index 00000000..a5c22304 --- /dev/null +++ b/poezio/tabs/rostertab.py @@ -0,0 +1,1280 @@ +""" +The RosterInfoTab is the tab showing roster info, the list of contacts, +half of it is dedicated to showing the information buffer, and a small +rectangle shows the current contact info. + +This module also includes functions to match users in the roster. +""" +import logging +log = logging.getLogger(__name__) + +import base64 +import curses +import difflib +import os +import ssl +from os import getenv, path +from functools import partial + +from . import Tab + +import common +import windows +from common import safeJID +from config import config +from contact import Contact, Resource +from decorators import refresh_wrapper +from roster import RosterGroup, roster +from theming import get_theme, dump_tuple +from decorators import command_args_parser + +class RosterInfoTab(Tab): + """ + A tab, splitted in two, containing the roster and infos + """ + plugin_commands = {} + plugin_keys = {} + def __init__(self): + Tab.__init__(self) + self.name = "Roster" + self.v_separator = windows.VerticalSeparator() + self.information_win = windows.TextWin() + self.core.information_buffer.add_window(self.information_win) + self.roster_win = windows.RosterWin() + self.contact_info_win = windows.ContactInfoWin() + self.default_help_message = windows.HelpText("Enter commands with “/”. “o”: toggle offline show") + self.input = self.default_help_message + self.state = 'normal' + self.key_func['^I'] = self.completion + self.key_func["/"] = self.on_slash + # disable most of the roster features when in anonymous mode + if not self.core.xmpp.anon: + self.key_func[' '] = self.on_space + self.key_func["KEY_UP"] = self.move_cursor_up + self.key_func["KEY_DOWN"] = self.move_cursor_down + self.key_func["M-u"] = self.move_cursor_to_next_contact + self.key_func["M-y"] = self.move_cursor_to_prev_contact + self.key_func["M-U"] = self.move_cursor_to_next_group + self.key_func["M-Y"] = self.move_cursor_to_prev_group + self.key_func["M-[1;5B"] = self.move_cursor_to_next_group + self.key_func["M-[1;5A"] = self.move_cursor_to_prev_group + self.key_func["l"] = self.command_last_activity + self.key_func["o"] = self.toggle_offline_show + self.key_func["v"] = self.get_contact_version + self.key_func["i"] = self.show_contact_info + self.key_func["s"] = self.start_search + self.key_func["S"] = self.start_search_slow + self.key_func["n"] = self.change_contact_name + self.register_command('deny', self.command_deny, + usage='[jid]', + desc='Deny your presence to the provided JID (or the ' + 'selected contact in your roster), who is asking' + 'you to be in his/here roster.', + shortdesc='Deny an user your presence.', + completion=self.completion_deny) + self.register_command('accept', self.command_accept, + usage='[jid]', + desc='Allow the provided JID (or the selected contact ' + 'in your roster), to see your presence.', + shortdesc='Allow an user your presence.', + completion=self.completion_deny) + self.register_command('add', self.command_add, + usage='<jid>', + desc='Add the specified JID to your roster, ask him to' + ' allow you to see his presence, and allow him to' + ' see your presence.', + shortdesc='Add an user to your roster.') + self.register_command('name', self.command_name, + usage='<jid> [name]', + shortdesc='Set the given JID\'s name.', + completion=self.completion_name) + self.register_command('groupadd', self.command_groupadd, + usage='<jid> <group>', + desc='Add the given JID to the given group.', + shortdesc='Add an user to a group', + completion=self.completion_groupadd) + self.register_command('groupmove', self.command_groupmove, + usage='<jid> <old group> <new group>', + desc='Move the given JID from the old group to the new group.', + shortdesc='Move an user to another group.', + completion=self.completion_groupmove) + self.register_command('groupremove', self.command_groupremove, + usage='<jid> <group>', + desc='Remove the given JID from the given group.', + shortdesc='Remove an user from a group.', + completion=self.completion_groupremove) + self.register_command('remove', self.command_remove, + usage='[jid]', + desc='Remove the specified JID from your roster. This ' + 'will unsubscribe you from its presence, cancel ' + 'its subscription to yours, and remove the item ' + 'from your roster.', + shortdesc='Remove an user from your roster.', + completion=self.completion_remove) + self.register_command('export', self.command_export, + usage='[/path/to/file]', + desc='Export your contacts into /path/to/file if ' + 'specified, or $HOME/poezio_contacts if not.', + shortdesc='Export your roster to a file.', + completion=partial(self.completion_file, 1)) + self.register_command('import', self.command_import, + usage='[/path/to/file]', + desc='Import your contacts from /path/to/file if ' + 'specified, or $HOME/poezio_contacts if not.', + shortdesc='Import your roster from a file.', + completion=partial(self.completion_file, 1)) + self.register_command('password', self.command_password, + usage='<password>', + shortdesc='Change your password') + + self.register_command('reconnect', self.command_reconnect, + desc='Disconnect from the remote server if you are ' + 'currently connected and then connect to it again.', + shortdesc='Disconnect and reconnect to the server.') + self.register_command('disconnect', self.command_disconnect, + desc='Disconnect from the remote server.', + shortdesc='Disconnect from the server.') + self.register_command('clear', self.command_clear, + shortdesc='Clear the info buffer.') + self.register_command('last_activity', self.command_last_activity, + usage='<jid>', + desc='Informs you of the last activity of a JID.', + shortdesc='Get the activity of someone.', + completion=self.core.completion_last_activity) + + self.resize() + self.update_commands() + self.update_keys() + + def check_blocking(self, features): + if 'urn:xmpp:blocking' in features and not self.core.xmpp.anon: + self.register_command('block', self.command_block, + usage='[jid]', + shortdesc='Prevent a JID from talking to you.', + completion=self.completion_block) + self.register_command('unblock', self.command_unblock, + usage='[jid]', + shortdesc='Allow a JID to talk to you.', + completion=self.completion_unblock) + self.register_command('list_blocks', self.command_list_blocks, + shortdesc='Show the blocked contacts.') + self.core.xmpp.del_event_handler('session_start', self.check_blocking) + self.core.xmpp.add_event_handler('blocked_message', self.on_blocked_message) + + def check_saslexternal(self, features): + if 'urn:xmpp:saslcert:1' in features and not self.core.xmpp.anon: + self.register_command('certs', self.command_certs, + desc='List the fingerprints of certificates' + ' which can connect to your account.', + shortdesc='List allowed client certs.') + self.register_command('cert_add', self.command_cert_add, + desc='Add a client certificate to the authorized ones. ' + 'It must have an unique name and be contained in ' + 'a PEM file. [management] is a boolean indicating' + ' if a client connected using this certificate can' + ' manage the certificates itself.', + shortdesc='Add a client certificate.', + usage='<name> <certificate path> [management]', + completion=self.completion_cert_add) + self.register_command('cert_disable', self.command_cert_disable, + desc='Remove a certificate from the list ' + 'of allowed ones. Clients currently ' + 'using this certificate will not be ' + 'forcefully disconnected.', + shortdesc='Disable a certificate', + usage='<name>') + self.register_command('cert_revoke', self.command_cert_revoke, + desc='Remove a certificate from the list ' + 'of allowed ones. Clients currently ' + 'using this certificate will be ' + 'forcefully disconnected.', + shortdesc='Revoke a certificate', + usage='<name>') + self.register_command('cert_fetch', self.command_cert_fetch, + desc='Retrieve a certificate with its ' + 'name. It will be stored in <path>.', + shortdesc='Fetch a certificate', + usage='<name> <path>', + completion=self.completion_cert_fetch) + + @property + def selected_row(self): + return self.roster_win.get_selected_row() + + @command_args_parser.ignored + def command_certs(self): + """ + /certs + """ + def cb(iq): + if iq['type'] == 'error': + self.core.information('Unable to retrieve the certificate list.', + 'Error') + return + certs = [] + for item in iq['sasl_certs']['items']: + users = '\n'.join(item['users']) + certs.append((item['name'], users)) + + if not certs: + return self.core.information('No certificates found', 'Info') + msg = 'Certificates:\n' + msg += '\n'.join(((' %s%s' % (item[0] + (': ' if item[1] else ''), item[1])) for item in certs)) + self.core.information(msg, 'Info') + + self.core.xmpp.plugin['xep_0257'].get_certs(callback=cb, timeout=3) + + @command_args_parser.quoted(2, 1) + def command_cert_add(self, args): + """ + /cert_add <name> <certfile> [cert-management] + """ + if not args or len(args) < 2: + return self.core.command_help('cert_add') + def cb(iq): + if iq['type'] == 'error': + self.core.information('Unable to add the certificate.', 'Error') + else: + self.core.information('Certificate added.', 'Info') + + name = args[0] + + try: + with open(args[1]) as fd: + crt = fd.read() + crt = crt.replace(ssl.PEM_FOOTER, '').replace(ssl.PEM_HEADER, '').replace(' ', '').replace('\n', '') + except Exception as e: + self.core.information('Unable to read the certificate: %s' % e, 'Error') + return + + if len(args) > 2: + management = args[2] + if management: + management = management.lower() + if management not in ('false', '0'): + management = True + else: + management = False + else: + management = False + else: + management = True + + self.core.xmpp.plugin['xep_0257'].add_cert(name, crt, callback=cb, + allow_management=management) + + def completion_cert_add(self, the_input): + """ + completion for /cert_add <name> <path> [management] + """ + text = the_input.get_text() + args = common.shell_split(text) + n = the_input.get_argument_position() + log.debug('%s %s %s', the_input.text, n, the_input.pos) + if n == 1: + return + elif n == 2: + return self.completion_file(2, the_input) + elif n == 3: + return the_input.new_completion(['true', 'false'], n) + + @command_args_parser.quoted(1) + def command_cert_disable(self, args): + """ + /cert_disable <name> + """ + if not args: + return self.core.command_help('cert_disable') + def cb(iq): + if iq['type'] == 'error': + self.core.information('Unable to disable the certificate.', 'Error') + else: + self.core.information('Certificate disabled.', 'Info') + + name = args[0] + + self.core.xmpp.plugin['xep_0257'].disable_cert(name, callback=cb) + + @command_args_parser.quoted(1) + def command_cert_revoke(self, args): + """ + /cert_revoke <name> + """ + if not args: + return self.core.command_help('cert_revoke') + def cb(iq): + if iq['type'] == 'error': + self.core.information('Unable to revoke the certificate.', 'Error') + else: + self.core.information('Certificate revoked.', 'Info') + + name = args[0] + + self.core.xmpp.plugin['xep_0257'].revoke_cert(name, callback=cb) + + + @command_args_parser.quoted(2) + def command_cert_fetch(self, args): + """ + /cert_fetch <name> <path> + """ + if not args or len(args) < 2: + return self.core.command_help('cert_fetch') + def cb(iq): + if iq['type'] == 'error': + self.core.information('Unable to fetch the certificate.', + 'Error') + return + + cert = None + for item in iq['sasl_certs']['items']: + if item['name'] == name: + cert = base64.b64decode(item['x509cert']) + break + + if not cert: + return self.core.information('Certificate not found.', 'Info') + + cert = ssl.DER_cert_to_PEM_cert(cert) + with open(path, 'w') as fd: + fd.write(cert) + + self.core.information('File stored at %s' % path, 'Info') + + name = args[0] + path = args[1] + + self.core.xmpp.plugin['xep_0257'].get_certs(callback=cb) + + def completion_cert_fetch(self, the_input): + """ + completion for /cert_fetch <name> <path> + """ + text = the_input.get_text() + args = common.shell_split(text) + n = the_input.get_argument_position() + log.debug('%s %s %s', the_input.text, n, the_input.pos) + if n == 1: + return + elif n == 2: + return self.completion_file(2, the_input) + + def on_blocked_message(self, message): + """ + When we try to send a message to a blocked contact + """ + tab = self.core.get_conversation_by_jid(message['from'], False) + if not tab: + log.debug('Received message from nonexistent tab: %s', message['from']) + message = '\x19%(info_col)s}Cannot send message to %(jid)s: contact blocked' % { + 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT), + 'jid': message['from'], + } + tab.add_message(message) + + @command_args_parser.quoted(0, 1) + def command_block(self, args): + """ + /block [jid] + """ + def callback(iq): + if iq['type'] == 'error': + return self.core.information('Could not block the contact.', 'Error') + elif iq['type'] == 'result': + return self.core.information('Contact blocked.', 'Info') + + item = self.roster_win.selected_row + if args: + jid = safeJID(args[0]) + elif isinstance(item, Contact): + jid = item.bare_jid + elif isinstance(item, Resource): + jid = item.jid.bare + self.core.xmpp.plugin['xep_0191'].block(jid, callback=callback) + + def completion_block(self, the_input): + """ + Completion for /block + """ + if the_input.get_argument_position() == 1: + jids = roster.jids() + return the_input.new_completion(jids, 1, '', quotify=False) + + @command_args_parser.quoted(0, 1) + def command_unblock(self, args): + """ + /unblock [jid] + """ + def callback(iq): + if iq['type'] == 'error': + return self.core.information('Could not unblock the contact.', 'Error') + elif iq['type'] == 'result': + return self.core.information('Contact unblocked.', 'Info') + + item = self.roster_win.selected_row + if args: + jid = safeJID(args[0]) + elif isinstance(item, Contact): + jid = item.bare_jid + elif isinstance(item, Resource): + jid = item.jid.bare + self.core.xmpp.plugin['xep_0191'].unblock(jid, callback=callback) + + def completion_unblock(self, the_input): + """ + Completion for /unblock + """ + def on_result(iq): + if iq['type'] == 'error': + return + l = sorted(str(item) for item in iq['blocklist']['items']) + return the_input.new_completion(l, 1, quotify=False) + + if the_input.get_argument_position(): + self.core.xmpp.plugin['xep_0191'].get_blocked(callback=on_result) + return True + + @command_args_parser.ignored + def command_list_blocks(self): + """ + /list_blocks + """ + def callback(iq): + if iq['type'] == 'error': + return self.core.information('Could not retrieve the blocklist.', 'Error') + s = 'List of blocked JIDs:\n' + items = (str(item) for item in iq['blocklist']['items']) + jids = '\n'.join(items) + if jids: + s += jids + else: + s = 'No blocked JIDs.' + self.core.information(s, 'Info') + + self.core.xmpp.plugin['xep_0191'].get_blocked(callback=callback) + + @command_args_parser.ignored + def command_reconnect(self): + """ + /reconnect + """ + if self.core.xmpp.is_connected(): + self.core.disconnect(reconnect=True) + else: + self.core.xmpp.connect() + + @command_args_parser.ignored + def command_disconnect(self): + """ + /disconnect + """ + self.core.disconnect() + + @command_args_parser.quoted(0, 1) + def command_last_activity(self, args): + """ + /activity [jid] + """ + item = self.roster_win.selected_row + if args: + jid = args[0] + elif isinstance(item, Contact): + jid = item.bare_jid + elif isinstance(item, Resource): + jid = item.jid + else: + self.core.information('No JID selected.', 'Error') + return + self.core.command_last_activity(jid) + + def resize(self): + self.need_resize = False + if self.size.tab_degrade_x: + display_info = False + roster_width = self.width + else: + display_info = True + roster_width = self.width // 2 + if self.size.tab_degrade_y: + display_contact_win = False + contact_win_h = 0 + else: + display_contact_win = True + contact_win_h = 4 + if self.size.tab_degrade_y: + tab_win_height = 0 + else: + tab_win_height = Tab.tab_win_height() + + info_width = self.width - roster_width - 1 + if display_info: + self.v_separator.resize(self.height - 1 - tab_win_height, + 1, 0, roster_width) + self.information_win.resize(self.height - 1 - tab_win_height + - contact_win_h, + info_width, 0, roster_width + 1, + self.core.information_buffer) + if display_contact_win: + self.contact_info_win.resize(contact_win_h, + info_width, + self.height - tab_win_height + - contact_win_h - 1, + roster_width + 1) + self.roster_win.resize(self.height - 1 - Tab.tab_win_height(), + roster_width, 0, 0) + self.input.resize(1, self.width, self.height-1, 0) + self.default_help_message.resize(1, self.width, self.height-1, 0) + + def completion(self): + # Check if we are entering a command (with the '/' key) + if isinstance(self.input, windows.Input) and\ + not self.input.help_message: + self.complete_commands(self.input) + + def completion_file(self, complete_number, the_input): + """ + Generic quoted completion for files/paths + (use functools.partial to use directly as a completion + for a command) + """ + text = the_input.get_text() + args = common.shell_split(text) + n = the_input.get_argument_position() + if n == complete_number: + if args[n-1] == '' or len(args) < n+1: + home = os.getenv('HOME') or '/' + return the_input.new_completion([home, '/tmp'], n, quotify=True) + path_ = args[n] + if path.isdir(path_): + dir_ = path_ + base = '' + else: + dir_ = path.dirname(path_) + base = path.basename(path_) + try: + names = os.listdir(dir_) + except OSError: + names = [] + names_filtered = [name for name in names if name.startswith(base)] + if names_filtered: + names = names_filtered + if not names: + names = [path_] + end_list = [] + for name in names: + value = os.path.join(dir_, name) + if not name.startswith('.'): + end_list.append(value) + + return the_input.new_completion(end_list, n, quotify=True) + + @command_args_parser.ignored + def command_clear(self): + """ + /clear + """ + self.core.information_buffer.messages = [] + self.information_win.rebuild_everything(self.core.information_buffer) + self.core.information_win.rebuild_everything(self.core.information_buffer) + self.refresh() + + @command_args_parser.quoted(1) + def command_password(self, args): + """ + /password <password> + """ + def callback(iq): + if iq['type'] == 'result': + self.core.information('Password updated', 'Account') + if config.get('password'): + config.silent_set('password', args[0]) + else: + self.core.information('Unable to change the password', 'Account') + self.core.xmpp.plugin['xep_0077'].change_password(args[0], callback=callback) + + @command_args_parser.quoted(0, 1) + def command_deny(self, args): + """ + /deny [jid] + Denies a JID from our roster + """ + if not args: + item = self.roster_win.selected_row + if isinstance(item, Contact): + jid = item.bare_jid + else: + self.core.information('No subscription to deny') + return + else: + jid = safeJID(args[0]).bare + if not jid in [jid for jid in roster.jids()]: + self.core.information('No subscription to deny') + return + + contact = roster[jid] + if contact: + contact.unauthorize() + self.core.information('Subscription to %s was revoked' % jid, + 'Roster') + + @command_args_parser.quoted(1) + def command_add(self, args): + """ + Add the specified JID to the roster, and set automatically + accept the reverse subscription + """ + if args is None: + self.core.information('No JID specified', 'Error') + return + jid = safeJID(safeJID(args[0]).bare) + if not str(jid): + self.core.information('The provided JID (%s) is not valid' % (args[0],), 'Error') + return + if jid in roster and roster[jid].subscription in ('to', 'both'): + return self.core.information('Already subscribed.', 'Roster') + roster.add(jid) + roster.modified() + self.core.information('%s was added to the roster' % jid, 'Roster') + + @command_args_parser.quoted(1, 1) + def command_name(self, args): + """ + Set a name for the specified JID in your roster + """ + def callback(iq): + if not iq: + self.core.information('The name could not be set.', 'Error') + log.debug('Error in /name:\n%s', iq) + if args is None: + return self.core.command_help('name') + jid = safeJID(args[0]).bare + name = args[1] if len(args) == 2 else '' + + contact = roster[jid] + if contact is None: + self.core.information('No such JID in roster', 'Error') + return + + groups = set(contact.groups) + if 'none' in groups: + groups.remove('none') + subscription = contact.subscription + self.core.xmpp.update_roster(jid, name=name, groups=groups, + subscription=subscription, callback=callback) + + @command_args_parser.quoted(2) + def command_groupadd(self, args): + """ + Add the specified JID to the specified group + """ + if args is None: + return self.core.command_help('groupadd') + jid = safeJID(args[0]).bare + group = args[1] + + contact = roster[jid] + if contact is None: + self.core.information('No such JID in roster', 'Error') + return + + new_groups = set(contact.groups) + if group in new_groups: + self.core.information('JID already in group', 'Error') + return + + roster.modified() + new_groups.add(group) + try: + new_groups.remove('none') + except KeyError: + pass + + name = contact.name + subscription = contact.subscription + + def callback(iq): + if iq: + roster.update_contact_groups(jid) + else: + self.core.information('The group could not be set.', 'Error') + log.debug('Error in groupadd:\n%s', iq) + + self.core.xmpp.update_roster(jid, name=name, groups=new_groups, + subscription=subscription, callback=callback) + + @command_args_parser.quoted(3) + def command_groupmove(self, args): + """ + Remove the specified JID from the first specified group and add it to the second one + """ + if args is None: + return self.core.command_help('groupmove') + jid = safeJID(args[0]).bare + group_from = args[1] + group_to = args[2] + + contact = roster[jid] + if not contact: + self.core.information('No such JID in roster', 'Error') + return + + new_groups = set(contact.groups) + if 'none' in new_groups: + new_groups.remove('none') + + if group_to == 'none' or group_from == 'none': + self.core.information('"none" is not a group.', 'Error') + return + + if group_from not in new_groups: + self.core.information('JID not in first group', 'Error') + return + + if group_to in new_groups: + self.core.information('JID already in second group', 'Error') + return + + if group_to == group_from: + self.core.information('The groups are the same.', 'Error') + return + + roster.modified() + new_groups.add(group_to) + if 'none' in new_groups: + new_groups.remove('none') + + new_groups.remove(group_from) + name = contact.name + subscription = contact.subscription + + def callback(iq): + if iq: + roster.update_contact_groups(contact) + else: + self.core.information('The group could not be set') + log.debug('Error in groupmove:\n%s', iq) + + self.core.xmpp.update_roster(jid, name=name, groups=new_groups, + subscription=subscription, callback=callback) + + @command_args_parser.quoted(2) + def command_groupremove(self, args): + """ + Remove the specified JID from the specified group + """ + if args is None: + return self.core.command_help('groupremove') + + jid = safeJID(args[0]).bare + group = args[1] + + contact = roster[jid] + if contact is None: + self.core.information('No such JID in roster', 'Error') + return + + new_groups = set(contact.groups) + try: + new_groups.remove('none') + except KeyError: + pass + if group not in new_groups: + self.core.information('JID not in group', 'Error') + return + + roster.modified() + + new_groups.remove(group) + name = contact.name + subscription = contact.subscription + + def callback(iq): + if iq: + roster.update_contact_groups(jid) + else: + self.core.information('The group could not be set') + log.debug('Error in groupremove:\n%s', iq) + + self.core.xmpp.update_roster(jid, name=name, groups=new_groups, + subscription=subscription, callback=callback) + + @command_args_parser.quoted(0, 1) + def command_remove(self, args): + """ + Remove the specified JID from the roster. i.e.: unsubscribe + from its presence, and cancel its subscription to our. + """ + if args: + jid = safeJID(args[0]).bare + else: + item = self.roster_win.selected_row + if isinstance(item, Contact): + jid = item.bare_jid + else: + self.core.information('No roster item to remove') + return + roster.remove(jid) + del roster[jid] + + @command_args_parser.quoted(0, 1) + def command_import(self, args): + """ + Import the contacts + """ + if args: + if args[0].startswith('/'): + filepath = args[0] + else: + filepath = path.join(getenv('HOME'), args[0]) + else: + filepath = path.join(getenv('HOME'), 'poezio_contacts') + if not path.isfile(filepath): + self.core.information('The file %s does not exist' % filepath, 'Error') + return + try: + handle = open(filepath, 'r', encoding='utf-8') + lines = handle.readlines() + handle.close() + except IOError: + self.core.information('Could not open %s' % filepath, 'Error') + log.error('Unable to correct a message', exc_info=True) + return + for jid in lines: + self.command_add(jid.lstrip('\n')) + self.core.information('Contacts imported from %s' % filepath, 'Info') + + @command_args_parser.quoted(0, 1) + def command_export(self, args): + """ + Export the contacts + """ + if args: + if args[0].startswith('/'): + filepath = args[0] + else: + filepath = path.join(getenv('HOME'), args[0]) + else: + filepath = path.join(getenv('HOME'), 'poezio_contacts') + if path.isfile(filepath): + self.core.information('The file already exists', 'Error') + return + elif not path.isdir(path.dirname(filepath)): + self.core.information('Parent directory not found', 'Error') + return + if roster.export(filepath): + self.core.information('Contacts exported to %s' % filepath, 'Info') + else: + self.core.information('Failed to export contacts to %s' % filepath, 'Info') + + def completion_remove(self, the_input): + """ + Completion for /remove + """ + jids = [jid for jid in roster.jids()] + return the_input.auto_completion(jids, '', quotify=False) + + def completion_name(self, the_input): + """Completion for /name""" + n = the_input.get_argument_position() + if n == 1: + jids = [jid for jid in roster.jids()] + return the_input.new_completion(jids, n, quotify=True) + return False + + def completion_groupadd(self, the_input): + n = the_input.get_argument_position() + if n == 1: + jids = sorted(jid for jid in roster.jids()) + return the_input.new_completion(jids, n, '', quotify=True) + elif n == 2: + groups = sorted(group for group in roster.groups if group != 'none') + return the_input.new_completion(groups, n, '', quotify=True) + return False + + def completion_groupmove(self, the_input): + args = common.shell_split(the_input.text) + n = the_input.get_argument_position() + if n == 1: + jids = sorted(jid for jid in roster.jids()) + return the_input.new_completion(jids, n, '', quotify=True) + elif n == 2: + contact = roster[args[1]] + if not contact: + return False + groups = list(contact.groups) + if 'none' in groups: + groups.remove('none') + return the_input.new_completion(groups, n, '', quotify=True) + elif n == 3: + groups = sorted(group for group in roster.groups) + return the_input.new_completion(groups, n, '', quotify=True) + return False + + def completion_groupremove(self, the_input): + args = common.shell_split(the_input.text) + n = the_input.get_argument_position() + if n == 1: + jids = sorted(jid for jid in roster.jids()) + return the_input.new_completion(jids, n, '', quotify=True) + elif n == 2: + contact = roster[args[1]] + if contact is None: + return False + groups = sorted(contact.groups) + try: + groups.remove('none') + except ValueError: + pass + return the_input.new_completion(groups, n, '', quotify=True) + return False + + def completion_deny(self, the_input): + """ + Complete the first argument from the list of the + contact with ask=='subscribe' + """ + jids = sorted(str(contact.bare_jid) for contact in roster.contacts.values() + if contact.pending_in) + return the_input.new_completion(jids, 1, '', quotify=False) + + @command_args_parser.quoted(0, 1) + def command_accept(self, args): + """ + Accept a JID from in roster. Authorize it AND subscribe to it + """ + if not args: + item = self.roster_win.selected_row + if isinstance(item, Contact): + jid = item.bare_jid + else: + self.core.information('No subscription to accept') + return + else: + jid = safeJID(args[0]).bare + nodepart = safeJID(jid).user + jid = safeJID(jid) + # crappy transports putting resources inside the node part + if '\\2f' in nodepart: + jid.user = nodepart.split('\\2f')[0] + contact = roster[jid] + if contact is None: + return + contact.pending_in = False + roster.modified() + self.core.xmpp.send_presence(pto=jid, ptype='subscribed') + self.core.xmpp.client_roster.send_last_presence() + if contact.subscription in ('from', 'none') and not contact.pending_out: + self.core.xmpp.send_presence(pto=jid, ptype='subscribe', pnick=self.core.own_nick) + + self.core.information('%s is now authorized' % jid, 'Roster') + + def refresh(self): + if self.need_resize: + self.resize() + log.debug(' TAB Refresh: %s', self.__class__.__name__) + + display_info = not self.size.tab_degrade_x + display_contact_win = not self.size.tab_degrade_y + + self.roster_win.refresh(roster) + if display_info: + self.v_separator.refresh() + self.information_win.refresh() + if display_contact_win: + self.contact_info_win.refresh( + self.roster_win.get_selected_row()) + self.refresh_tab_win() + self.input.refresh() + + def on_input(self, key, raw): + if key == '^M': + selected_row = self.roster_win.get_selected_row() + res = self.input.do_command(key, raw=raw) + if res and not isinstance(self.input, windows.Input): + return True + elif res: + return False + if key == '^M': + self.core.on_roster_enter_key(selected_row) + return selected_row + elif not raw and key in self.key_func: + return self.key_func[key]() + + @refresh_wrapper.conditional + def toggle_offline_show(self): + """ + Show or hide offline contacts + """ + option = 'roster_show_offline' + value = config.get(option) + success = config.silent_set(option, str(not value)) + roster.modified() + if not success: + self.core.information('Unable to write in the config file', 'Error') + return True + + def on_slash(self): + """ + '/' is pressed, we enter "input mode" + """ + if isinstance(self.input, windows.YesNoInput): + return + curses.curs_set(1) + self.input = windows.CommandInput("", self.reset_help_message, self.execute_slash_command) + self.input.resize(1, self.width, self.height-1, 0) + self.input.do_command("/") # we add the slash + + def reset_help_message(self, _=None): + self.input = self.default_help_message + if self.core.current_tab() is self: + curses.curs_set(0) + self.input.refresh() + self.core.doupdate() + return True + + def execute_slash_command(self, txt): + if txt.startswith('/'): + self.input.key_enter() + self.execute_command(txt) + return self.reset_help_message() + + def on_lose_focus(self): + self.state = 'normal' + + def on_gain_focus(self): + self.state = 'current' + if isinstance(self.input, windows.HelpText): + curses.curs_set(0) + else: + curses.curs_set(1) + + @refresh_wrapper.conditional + def move_cursor_down(self): + if isinstance(self.input, windows.Input) and not self.input.history_disabled: + return + return self.roster_win.move_cursor_down() + + @refresh_wrapper.conditional + def move_cursor_up(self): + if isinstance(self.input, windows.Input) and not self.input.history_disabled: + return + return self.roster_win.move_cursor_up() + + def move_cursor_to_prev_contact(self): + self.roster_win.move_cursor_up() + while not isinstance(self.roster_win.get_selected_row(), Contact): + if not self.roster_win.move_cursor_up(): + break + self.roster_win.refresh(roster) + + def move_cursor_to_next_contact(self): + self.roster_win.move_cursor_down() + while not isinstance(self.roster_win.get_selected_row(), Contact): + if not self.roster_win.move_cursor_down(): + break + self.roster_win.refresh(roster) + + def move_cursor_to_prev_group(self): + self.roster_win.move_cursor_up() + while not isinstance(self.roster_win.get_selected_row(), RosterGroup): + if not self.roster_win.move_cursor_up(): + break + self.roster_win.refresh(roster) + + def move_cursor_to_next_group(self): + self.roster_win.move_cursor_down() + while not isinstance(self.roster_win.get_selected_row(), RosterGroup): + if not self.roster_win.move_cursor_down(): + break + self.roster_win.refresh(roster) + + def on_scroll_down(self): + return self.roster_win.move_cursor_down(self.height // 2) + + def on_scroll_up(self): + return self.roster_win.move_cursor_up(self.height // 2) + + @refresh_wrapper.conditional + def on_space(self): + if isinstance(self.input, windows.Input): + return + selected_row = self.roster_win.get_selected_row() + if isinstance(selected_row, RosterGroup): + selected_row.toggle_folded() + roster.modified() + return True + elif isinstance(selected_row, Contact): + group = "none" + found_group = False + pos = self.roster_win.pos + while not found_group and pos >= 0: + row = self.roster_win.roster_cache[pos] + pos -= 1 + if isinstance(row, RosterGroup): + found_group = True + group = row.name + selected_row.toggle_folded(group) + roster.modified() + return True + return False + + def get_contact_version(self): + """ + Show the versions of the resource(s) currently selected + """ + selected_row = self.roster_win.get_selected_row() + if isinstance(selected_row, Contact): + for resource in selected_row.resources: + self.core.command_version(str(resource.jid)) + elif isinstance(selected_row, Resource): + self.core.command_version(str(selected_row.jid)) + else: + self.core.information('Nothing to get versions from', 'Info') + + def show_contact_info(self): + """ + Show the contact info (resource number, status, presence, etc) + when 'i' is pressed. + """ + selected_row = self.roster_win.get_selected_row() + if isinstance(selected_row, Contact): + cont = selected_row + res = selected_row.get_highest_priority_resource() + acc = [] + acc.append('Contact: %s (%s)' % (cont.bare_jid, res.presence if res else 'unavailable')) + if res: + acc.append('%s connected resource%s' % (len(cont), '' if len(cont) == 1 else 's')) + acc.append('Current status: %s' % res.status) + if cont.tune: + acc.append('Tune: %s' % common.format_tune_string(cont.tune)) + if cont.mood: + acc.append('Mood: %s' % cont.mood) + if cont.activity: + acc.append('Activity: %s' % cont.activity) + if cont.gaming: + acc.append('Game: %s' % (common.format_gaming_string(cont.gaming))) + msg = '\n'.join(acc) + elif isinstance(selected_row, Resource): + res = selected_row + msg = 'Resource: %s (%s)\nCurrent status: %s\nPriority: %s' % ( + res.jid, + res.presence, + res.status, + res.priority) + elif isinstance(selected_row, RosterGroup): + rg = selected_row + msg = 'Group: %s [%s/%s] contacts online' % ( + rg.name, + rg.get_nb_connected_contacts(), + len(rg),) + else: + msg = None + if msg: + self.core.information(msg, 'Info') + + def change_contact_name(self): + """ + Auto-fill a /name command when 'n' is pressed + """ + selected_row = self.roster_win.get_selected_row() + if isinstance(selected_row, Contact): + jid = selected_row.bare_jid + elif isinstance(selected_row, Resource): + jid = safeJID(selected_row.jid).bare + else: + return + self.on_slash() + self.input.text = '/name "%s" ' % jid + self.input.key_end() + self.input.refresh() + + @refresh_wrapper.always + def start_search(self): + """ + Start the search. The input should appear with a short instruction + in it. + """ + if isinstance(self.input, windows.YesNoInput): + return + curses.curs_set(1) + self.input = windows.CommandInput("[Search]", self.on_search_terminate, self.on_search_terminate, self.set_roster_filter) + self.input.resize(1, self.width, self.height-1, 0) + self.input.disable_history() + roster.modified() + self.refresh() + return True + + @refresh_wrapper.always + def start_search_slow(self): + if isinstance(self.input, windows.YesNoInput): + return + curses.curs_set(1) + self.input = windows.CommandInput("[Search]", self.on_search_terminate, self.on_search_terminate, self.set_roster_filter_slow) + self.input.resize(1, self.width, self.height-1, 0) + self.input.disable_history() + return True + + def set_roster_filter_slow(self, txt): + roster.contact_filter = (jid_and_name_match_slow, txt) + roster.modified() + self.refresh() + return False + + def set_roster_filter(self, txt): + roster.contact_filter = (jid_and_name_match, txt) + roster.modified() + self.refresh() + return False + + @refresh_wrapper.always + def on_search_terminate(self, txt): + curses.curs_set(0) + roster.contact_filter = None + self.reset_help_message() + roster.modified() + return True + + def on_close(self): + return + +def diffmatch(search, string): + """ + Use difflib and a loop to check if search_pattern can + be 'almost' found INSIDE a string. + 'almost' being defined by difflib + """ + if len(search) > len(string): + return False + l = len(search) + ratio = 0.7 + for i in range(len(string) - l + 1): + if difflib.SequenceMatcher(None, search, string[i:i+l]).ratio() >= ratio: + return True + return False + +def jid_and_name_match(contact, txt): + """ + Match jid with text precisely + """ + if not txt: + return True + txt = txt.lower() + if txt in safeJID(contact.bare_jid).bare.lower(): + return True + if txt in contact.name.lower(): + return True + return False + +def jid_and_name_match_slow(contact, txt): + """ + A function used to know if a contact in the roster should + be shown in the roster + """ + if not txt: + return True # Everything matches when search is empty + user = safeJID(contact.bare_jid).bare + if diffmatch(txt, user): + return True + if contact.name and diffmatch(txt, contact.name): + return True + return False diff --git a/poezio/tabs/xmltab.py b/poezio/tabs/xmltab.py new file mode 100644 index 00000000..b063ad35 --- /dev/null +++ b/poezio/tabs/xmltab.py @@ -0,0 +1,360 @@ +""" +The XMLTab is here for debugging purposes, it shows the incoming and +outgoing stanzas. It has a few useful functions that can filter stanzas +in order to only show the relevant ones, and it can also be frozen or +unfrozen on demand so that the relevant information is not drowned by +the traffic. +""" +import logging +log = logging.getLogger(__name__) + +import curses +import os +from slixmpp.xmlstream import matcher +from slixmpp.xmlstream.tostring import tostring +from slixmpp.xmlstream.stanzabase import ElementBase +from xml.etree import ElementTree as ET + +from . import Tab + +import text_buffer +import windows +from xhtml import clean_text +from decorators import command_args_parser, refresh_wrapper +from common import safeJID + + +class MatchJID(object): + + def __init__(self, jid, dest=''): + self.jid = jid + self.dest = dest + + def match(self, xml): + from_ = safeJID(xml['from']) + to_ = safeJID(xml['to']) + if self.jid.full == self.jid.bare: + from_ = from_.bare + to_ = to_.bare + + if self.dest == 'from': + return from_ == self.jid + elif self.dest == 'to': + return to_ == self.jid + return self.jid in (from_, to_) + + def __repr__(self): + return '%s%s%s' % (self.dest, ': ' if self.dest else '', self.jid) + +MATCHERS_MAPPINGS = { + MatchJID: ('JID', lambda obj: repr(obj)), + matcher.MatcherId: ('ID', lambda obj: obj._criteria), + matcher.MatchXMLMask: ('XMLMask', lambda obj: tostring(obj._criteria)), + matcher.MatchXPath: ('XPath', lambda obj: obj._criteria) +} + +class XMLTab(Tab): + def __init__(self): + Tab.__init__(self) + self.state = 'normal' + self.name = 'XMLTab' + self.filters = [] + + self.core_buffer = self.core.xml_buffer + self.filtered_buffer = text_buffer.TextBuffer() + + self.info_header = windows.XMLInfoWin() + self.text_win = windows.XMLTextWin() + self.core_buffer.add_window(self.text_win) + self.default_help_message = windows.HelpText("/ to enter a command") + + self.register_command('close', self.close, + shortdesc="Close this tab.") + self.register_command('clear', self.command_clear, + shortdesc='Clear the current buffer.') + self.register_command('reset', self.command_reset, + shortdesc='Reset the stanza filter.') + self.register_command('filter_id', self.command_filter_id, + usage='<id>', + desc='Show only the stanzas with the id <id>.', + shortdesc='Filter by id.') + self.register_command('filter_xpath', self.command_filter_xpath, + usage='<xpath>', + desc='Show only the stanzas matching the xpath <xpath>.' + ' Any occurrences of %n will be replaced by jabber:client.', + shortdesc='Filter by XPath.') + self.register_command('filter_jid', self.command_filter_jid, + usage='<jid>', + desc='Show only the stanzas matching the jid <jid> in from= or to=.', + shortdesc='Filter by JID.') + self.register_command('filter_from', self.command_filter_from, + usage='<jid>', + desc='Show only the stanzas matching the jid <jid> in from=.', + shortdesc='Filter by JID from.') + self.register_command('filter_to', self.command_filter_to, + usage='<jid>', + desc='Show only the stanzas matching the jid <jid> in to=.', + shortdesc='Filter by JID to.') + self.register_command('filter_xmlmask', self.command_filter_xmlmask, + usage='<xml mask>', + desc='Show only the stanzas matching the given xml mask.', + shortdesc='Filter by xml mask.') + self.register_command('dump', self.command_dump, + usage='<filename>', + desc='Writes the content of the XML buffer into a file.', + shortdesc='Write in a file.') + self.input = self.default_help_message + self.key_func['^T'] = self.close + self.key_func['^I'] = self.completion + self.key_func["KEY_DOWN"] = self.on_scroll_down + self.key_func["KEY_UP"] = self.on_scroll_up + self.key_func["^K"] = self.on_freeze + self.key_func["/"] = self.on_slash + self.resize() + # Used to display the infobar + self.filter_type = '' + self.filter = '' + + def gen_filter_repr(self): + if not self.filters: + self.filter_type = '' + self.filter = '' + return + filter_types = map(lambda x: MATCHERS_MAPPINGS[type(x)][0], self.filters) + filter_strings = map(lambda x: MATCHERS_MAPPINGS[type(x)][1](x), self.filters) + self.filter_type = ','.join(filter_types) + self.filter = ','.join(filter_strings) + + def update_filters(self, matcher): + if not self.filters: + messages = self.core_buffer.messages[:] + self.filtered_buffer.messages = [] + self.core_buffer.del_window(self.text_win) + self.filtered_buffer.add_window(self.text_win) + else: + messages = self.filtered_buffer.messages + self.filtered_buffer.messages = [] + self.filters.append(matcher) + new_messages = [] + for msg in messages: + try: + if msg.txt.strip() and self.match_stanza(ElementBase(ET.fromstring(clean_text(msg.txt)))): + new_messages.append(msg) + except ET.ParseError: + log.debug('Malformed XML : %s', msg.txt, exc_info=True) + self.filtered_buffer.messages = new_messages + self.text_win.rebuild_everything(self.filtered_buffer) + self.gen_filter_repr() + + def on_freeze(self): + """ + Freeze the display. + """ + self.text_win.toggle_lock() + self.refresh() + + def match_stanza(self, stanza): + for matcher in self.filters: + if not matcher.match(stanza): + return False + return True + + @command_args_parser.raw + def command_filter_xmlmask(self, mask): + """/filter_xmlmask <xml mask>""" + try: + self.update_filters(matcher.MatchXMLMask(mask)) + self.refresh() + except Exception as e: + self.core.information('Invalid XML Mask: %s' % e, 'Error') + self.command_reset('') + + @command_args_parser.raw + def command_filter_to(self, jid): + """/filter_jid_to <jid>""" + jid_obj = safeJID(jid) + if not jid_obj: + return self.core.information('Invalid JID: %s' % jid, 'Error') + + self.update_filters(MatchJID(jid_obj, dest='to')) + self.refresh() + + @command_args_parser.raw + def command_filter_from(self, jid): + """/filter_jid_from <jid>""" + jid_obj = safeJID(jid) + if not jid_obj: + return self.core.information('Invalid JID: %s' % jid, 'Error') + + self.update_filters(MatchJID(jid_obj, dest='from')) + self.refresh() + + @command_args_parser.raw + def command_filter_jid(self, jid): + """/filter_jid <jid>""" + jid_obj = safeJID(jid) + if not jid_obj: + return self.core.information('Invalid JID: %s' % jid, 'Error') + + self.update_filters(MatchJID(jid_obj)) + self.refresh() + + @command_args_parser.quoted(1) + def command_filter_id(self, args): + """/filter_id <id>""" + if args is None: + return self.core.command_help('filter_id') + + self.update_filters(matcher.MatcherId(args[0])) + self.refresh() + + @command_args_parser.raw + def command_filter_xpath(self, xpath): + """/filter_xpath <xpath>""" + try: + self.update_filters(matcher.MatchXPath(xpath.replace('%n', self.core.xmpp.default_ns))) + self.refresh() + except: + self.core.information('Invalid XML Path', 'Error') + self.command_reset('') + + @command_args_parser.ignored + def command_reset(self): + """/reset""" + if self.filters: + self.filters = [] + self.filtered_buffer.del_window(self.text_win) + self.core_buffer.add_window(self.text_win) + self.text_win.rebuild_everything(self.core_buffer) + self.filter_type = '' + self.filter = '' + self.refresh() + + @command_args_parser.quoted(1) + def command_dump(self, args): + """/dump <filename>""" + if args is None: + return self.core.command_help('dump') + if self.filters: + xml = self.filtered_buffer.messages[:] + else: + xml = self.core_buffer.messages[:] + text = '\n'.join(('%s %s %s' % (msg.str_time, msg.nickname, clean_text(msg.txt)) for msg in xml)) + filename = os.path.expandvars(os.path.expanduser(args[0])) + try: + with open(filename, 'w') as fd: + fd.write(text) + except Exception as e: + self.core.information('Could not write the XML dump: %s' % e, 'Error') + + def on_slash(self): + """ + '/' is pressed, activate the input + """ + curses.curs_set(1) + self.input = windows.CommandInput("", self.reset_help_message, self.execute_slash_command) + self.input.resize(1, self.width, self.height-1, 0) + self.input.do_command("/") # we add the slash + + @refresh_wrapper.always + def reset_help_message(self, _=None): + if self.closed: + return True + if self.core.current_tab() is self: + curses.curs_set(0) + self.input = self.default_help_message + return True + + def on_scroll_up(self): + return self.text_win.scroll_up(self.text_win.height-1) + + def on_scroll_down(self): + return self.text_win.scroll_down(self.text_win.height-1) + + @command_args_parser.ignored + def command_clear(self): + """ + /clear + """ + if self.filters: + buffer = self.core_buffer + else: + buffer = self.filtered_buffer + buffer.messages = [] + self.text_win.rebuild_everything(buffer) + self.refresh() + self.core.doupdate() + + def execute_slash_command(self, txt): + if txt.startswith('/'): + self.input.key_enter() + self.execute_command(txt) + return self.reset_help_message() + + def completion(self): + if isinstance(self.input, windows.Input): + self.complete_commands(self.input) + + def on_input(self, key, raw): + res = self.input.do_command(key, raw=raw) + if res: + return True + if not raw and key in self.key_func: + return self.key_func[key]() + + def close(self, arg=None): + self.core.close_tab() + + def resize(self): + self.need_resize = False + if self.size.tab_degrade_y: + info_win_size = 0 + tab_win_height = 0 + else: + info_win_size = self.core.information_win_size + tab_win_height = Tab.tab_win_height() + + self.text_win.resize(self.height - info_win_size - tab_win_height - 2, + self.width, 0, 0) + self.text_win.rebuild_everything(self.core.xml_buffer) + self.info_header.resize(1, self.width, + self.height - 2 - info_win_size + - tab_win_height, + 0) + self.input.resize(1, self.width, self.height-1, 0) + + def refresh(self): + if self.need_resize: + self.resize() + log.debug(' TAB Refresh: %s', self.__class__.__name__) + + if self.size.tab_degrade_y: + display_info_win = False + else: + display_info_win = True + + self.text_win.refresh() + self.info_header.refresh(self.filter_type, self.filter, self.text_win) + self.refresh_tab_win() + if display_info_win: + self.info_win.refresh() + self.input.refresh() + + def on_lose_focus(self): + self.state = 'normal' + + def on_gain_focus(self): + self.state = 'current' + curses.curs_set(0) + + def on_close(self): + self.command_clear('') + self.core.xml_tab = False + + def on_info_win_size_changed(self): + if self.core.information_win_size >= self.height-3: + return + self.text_win.resize(self.height-2-self.core.information_win_size - Tab.tab_win_height(), self.width, 0, 0) + self.info_header.resize(1, self.width, self.height-2-self.core.information_win_size - Tab.tab_win_height(), 0) + + |