Mercurial > libervia-backend
changeset 1969:5fbe09b9b568
merged main branch
author | Goffi <goffi@goffi.org> |
---|---|
date | Fri, 24 Jun 2016 22:41:28 +0200 |
parents | de6faf9be715 (diff) eca59bc4e6c6 (current diff) |
children | 200cd707a46d |
files | frontends/src/jp/cmd_blog.py frontends/src/jp/cmd_profile.py frontends/src/primitivus/constants.py frontends/src/primitivus/primitivus |
diffstat | 52 files changed, 2605 insertions(+), 1225 deletions(-) [+] |
line wrap: on
line diff
--- a/frontends/src/bridge/DBus.py Fri Jun 24 22:32:58 2016 +0200 +++ b/frontends/src/bridge/DBus.py Fri Jun 24 22:41:28 2016 +0200 @@ -336,15 +336,6 @@ error_handler = lambda err:errback(dbus_to_bridge_exception(err)) return self.db_core_iface.getFeatures(profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler) - def getHistory(self, from_jid, to_jid, limit, between=True, search='', profile="@NONE@", callback=None, errback=None): - if callback is None: - error_handler = None - else: - if errback is None: - errback = log.error - error_handler = lambda err:errback(dbus_to_bridge_exception(err)) - return self.db_core_iface.getHistory(from_jid, to_jid, limit, between, search, profile, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler) - def getMainResource(self, contact_jid, profile_key="@DEFAULT@", callback=None, errback=None): if callback is None: error_handler = None @@ -517,6 +508,15 @@ kwargs['error_handler'] = error_handler return self.db_core_iface.getWaitingSub(profile_key, **kwargs) + def historyGet(self, from_jid, to_jid, limit, between=True, search='', profile="@NONE@", callback=None, errback=None): + if callback is None: + error_handler = None + else: + if errback is None: + errback = log.error + error_handler = lambda err:errback(dbus_to_bridge_exception(err)) + return self.db_core_iface.historyGet(from_jid, to_jid, limit, between, search, profile, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler) + def isConnected(self, profile_key="@DEFAULT@", callback=None, errback=None): if callback is None: error_handler = None @@ -554,6 +554,15 @@ kwargs['error_handler'] = error_handler return self.db_core_iface.loadParamsTemplate(filename, **kwargs) + def messageSend(self, to_jid, message, subject={}, mess_type="auto", extra={}, profile_key="@NONE@", callback=None, errback=None): + if callback is None: + error_handler = None + else: + if errback is None: + errback = log.error + error_handler = lambda err:errback(dbus_to_bridge_exception(err)) + return self.db_core_iface.messageSend(to_jid, message, subject, mess_type, extra, profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler) + def paramsRegisterApp(self, xml, security_limit=-1, app='', callback=None, errback=None): if callback is None: error_handler = None @@ -661,15 +670,6 @@ kwargs['error_handler'] = error_handler return self.db_core_iface.saveParamsTemplate(filename, **kwargs) - def sendMessage(self, to_jid, message, subject='', mess_type="auto", extra={}, profile_key="@NONE@", callback=None, errback=None): - if callback is None: - error_handler = None - else: - if errback is None: - errback = log.error - error_handler = lambda err:errback(dbus_to_bridge_exception(err)) - return self.db_core_iface.sendMessage(to_jid, message, subject, mess_type, extra, profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler) - def setParam(self, name, value, category, security_limit=-1, profile_key="@DEFAULT@", callback=None, errback=None): if callback is None: error_handler = None
--- a/frontends/src/jp/cmd_adhoc.py Fri Jun 24 22:32:58 2016 +0200 +++ b/frontends/src/jp/cmd_adhoc.py Fri Jun 24 22:41:28 2016 +0200 @@ -1,4 +1,4 @@ -#! /usr/bin/python +#!/usr/bin/env python2 # -*- coding: utf-8 -*- # jp: a SAT command line tool
--- a/frontends/src/jp/cmd_blog.py Fri Jun 24 22:32:58 2016 +0200 +++ b/frontends/src/jp/cmd_blog.py Fri Jun 24 22:41:28 2016 +0200 @@ -1,4 +1,4 @@ -#! /usr/bin/python +#!/usr/bin/env python2 # -*- coding: utf-8 -*- # jp: a SàT command line tool
--- a/frontends/src/jp/cmd_bookmarks.py Fri Jun 24 22:32:58 2016 +0200 +++ b/frontends/src/jp/cmd_bookmarks.py Fri Jun 24 22:41:28 2016 +0200 @@ -1,4 +1,4 @@ -#! /usr/bin/python +#!/usr/bin/env python2 # -*- coding: utf-8 -*- # jp: a SAT command line tool
--- a/frontends/src/jp/cmd_file.py Fri Jun 24 22:32:58 2016 +0200 +++ b/frontends/src/jp/cmd_file.py Fri Jun 24 22:41:28 2016 +0200 @@ -1,4 +1,4 @@ -#! /usr/bin/python +#!/usr/bin/env python2 # -*- coding: utf-8 -*- # jp: a SAT command line tool
--- a/frontends/src/jp/cmd_info.py Fri Jun 24 22:32:58 2016 +0200 +++ b/frontends/src/jp/cmd_info.py Fri Jun 24 22:41:28 2016 +0200 @@ -1,4 +1,4 @@ -#! /usr/bin/python +#!/usr/bin/env python2 # -*- coding: utf-8 -*- # jp: a SAT command line tool
--- a/frontends/src/jp/cmd_message.py Fri Jun 24 22:32:58 2016 +0200 +++ b/frontends/src/jp/cmd_message.py Fri Jun 24 22:41:28 2016 +0200 @@ -1,4 +1,4 @@ -#! /usr/bin/python +#!/usr/bin/env python2 # -*- coding: utf-8 -*- # jp: a SAT command line tool @@ -20,6 +20,7 @@ from sat_frontends.jp import base import sys from sat.core.i18n import _ +from sat.core.constants import Const as C from sat.tools.utils import clean_ustr __commands__ = ["Message"] @@ -31,9 +32,13 @@ super(Send, self).__init__(host, 'send', help=_('send a message to a contact')) def add_parser_options(self): + self.parser.add_argument("-l", "--lang", type=str, default='', help=_("language of the message")) self.parser.add_argument("-s", "--separate", action="store_true", help=_("separate xmpp messages: send one message per line instead of one message alone.")) self.parser.add_argument("-n", "--new-line", action="store_true", help=_("add a new line at the beginning of the input (usefull for ascii art ;))")) - self.parser.add_argument("jid", type=str, help=_("the destination jid")) + self.parser.add_argument("-S", "--subject", type=base.unicode_decoder, help=_("subject of the message")) + self.parser.add_argument("-L", "--subject_lang", type=str, default='', help=_("language of subject")) + self.parser.add_argument("-t", "--type", choices=C.MESS_TYPE_STANDARD + (C.MESS_TYPE_AUTO,), default=C.MESS_TYPE_AUTO, help=_("type of the message")) + self.parser.add_argument("jid", type=base.unicode_decoder, help=_("the destination jid")) def start(self): jids = self.host.check_jids([self.args.jid]) @@ -46,21 +51,25 @@ @param dest_jid: destination jid """ header = "\n" if self.args.new_line else "" + if self.args.subject is None: + subject = {} + else: + subject = {self.args.subject_lang: self.args.subject} if self.args.separate: #we send stdin in several messages if header: - self.host.bridge.sendMessage(dest_jid, header, profile_key=self.profile, callback=lambda: None, errback=lambda ignore: ignore) + self.host.bridge.messageSend(dest_jid, {self.args.lang: header}, subject, self.args.type, profile_key=self.profile, callback=lambda: None, errback=lambda ignore: ignore) while (True): line = clean_ustr(sys.stdin.readline().decode('utf-8','ignore')) if not line: break - self.host.bridge.sendMessage(dest_jid, line.replace("\n",""), profile_key=self.host.profile, callback=lambda: None, errback=lambda ignore: ignore) + self.host.bridge.messageSend(dest_jid, {self.args.lang: line.replace("\n","")}, subject, self.args.type, profile_key=self.host.profile, callback=lambda: None, errback=lambda ignore: ignore) else: msg = header + clean_ustr(u"".join([stream.decode('utf-8','ignore') for stream in sys.stdin.readlines()])) - self.host.bridge.sendMessage(dest_jid, msg, profile_key=self.host.profile, callback=lambda: None, errback=lambda ignore: ignore) + self.host.bridge.messageSend(dest_jid, {self.args.lang: msg}, subject, self.args.type, profile_key=self.host.profile, callback=lambda: None, errback=lambda ignore: ignore) class Message(base.CommandBase):
--- a/frontends/src/jp/cmd_param.py Fri Jun 24 22:32:58 2016 +0200 +++ b/frontends/src/jp/cmd_param.py Fri Jun 24 22:41:28 2016 +0200 @@ -1,4 +1,4 @@ -#! /usr/bin/python +#!/usr/bin/env python2 # -*- coding: utf-8 -*- # jp: a SAT command line tool
--- a/frontends/src/jp/cmd_pipe.py Fri Jun 24 22:32:58 2016 +0200 +++ b/frontends/src/jp/cmd_pipe.py Fri Jun 24 22:41:28 2016 +0200 @@ -1,4 +1,4 @@ -#! /usr/bin/python +#!/usr/bin/env python2 # -*- coding: utf-8 -*- # jp: a SAT command line tool
--- a/frontends/src/jp/cmd_profile.py Fri Jun 24 22:32:58 2016 +0200 +++ b/frontends/src/jp/cmd_profile.py Fri Jun 24 22:41:28 2016 +0200 @@ -1,4 +1,4 @@ -#! /usr/bin/python +#!/usr/bin/env python2 # -*- coding: utf-8 -*- # jp: a SAT command line tool
--- a/frontends/src/jp/cmd_roster.py Fri Jun 24 22:32:58 2016 +0200 +++ b/frontends/src/jp/cmd_roster.py Fri Jun 24 22:41:28 2016 +0200 @@ -1,4 +1,4 @@ -#! /usr/bin/python +#!/usr/bin/env python2 # -*- coding: utf-8 -*- # jp: a SAT command line tool
--- a/frontends/src/primitivus/chat.py Fri Jun 24 22:32:58 2016 +0200 +++ b/frontends/src/primitivus/chat.py Fri Jun 24 22:41:28 2016 +0200 @@ -23,7 +23,7 @@ import urwid from urwid_satext import sat_widgets from sat_frontends.quick_frontend import quick_widgets -from sat_frontends.quick_frontend.quick_chat import QuickChat +from sat_frontends.quick_frontend import quick_chat from sat_frontends.quick_frontend import quick_games from sat_frontends.primitivus import game_tarot from sat_frontends.primitivus.constants import Const as C @@ -31,19 +31,63 @@ from sat_frontends.primitivus.widget import PrimitivusWidget import time from sat_frontends.tools import jid +from functools import total_ordering +import bisect -class ChatText(urwid.FlowWidget): - """Manage the printing of chat message""" +class MessageWidget(urwid.WidgetWrap): + + def __init__(self, mess_data): + """ + @param mess_data(quick_chat.Message, None): message data + None: used only for non text widgets (e.g.: focus separator) + """ + self.mess_data = mess_data + mess_data.widgets.add(self) + self.timestamp = time.localtime(mess_data.timestamp) + super(MessageWidget, self).__init__(urwid.Text(self.markup)) + + @property + def markup(self): + return self._generateInfoMarkup() if self.mess_data.type == C.MESS_TYPE_INFO else self._generateMarkup() + + @property + def info_type(self): + return self.mess_data.info_type + + @property + def parent(self): + return self.mess_data.parent - def __init__(self, parent, timestamp, nick, my_mess, message, align='left', is_info=False): - self.parent = parent - self.timestamp = time.localtime(timestamp) - self.nick = nick - self.my_mess = my_mess - self.message = unicode(message) - self.align = align - self.is_info = is_info + @property + def message(self): + """Return currently displayed message""" + message = self.mess_data.message + if self.parent.lang in message: + self.selected_lang = self.parent.lang + return message[self.parent.lang] + try: + self.selected_lang = '' + return message[''] + except KeyError: + try: + lang, mess = message.iteritems().next() + self.selected_lang = lang + return mess + except StopIteration: + log.error(u"Can't find message for uid {}".format(self.mess_data.uid)) + + @message.setter + def message(self, value): + self.mess_data.message = {'':value} + self._w.set_text(self.markup) + + @property + def type(self): + try: + return self.mess_data.type + except AttributeError: + return C.MESS_TYPE_INFO def selectable(self): return True @@ -51,43 +95,104 @@ def keypress(self, size, key): return key - def rows(self, size, focus=False): - return self.display_widget(size, focus).rows(size, focus) + def get_cursor_coords(self, size): + return 0, 0 def render(self, size, focus=False): - canvas = urwid.CompositeCanvas(self.display_widget(size, focus).render(size, focus)) + # Text widget doesn't render cursor, but we want one + # so we add it here + canvas = urwid.CompositeCanvas(self._w.render(size, focus)) if focus: canvas.set_cursor(self.get_cursor_coords(size)) return canvas + def _generateInfoMarkup(self): + return ('info_msg', self.message) + + def _generateMarkup(self): + """Generate text markup according to message data and Widget options""" + markup = [] + d = self.mess_data + + # timestamp + if self.parent.show_timestamp: + # if the message was sent before today, we print the full date + time_format = "%c" if self.timestamp < self.parent.day_change else "%H:%M" + markup.append(('date', "[{}]".format(time.strftime(time_format, self.timestamp).decode('utf-8')))) + + # nickname + if self.parent.show_short_nick: + markup.append(('my_nick' if d.own_mess else 'other_nick', "**" if d.own_mess else "*")) + else: + markup.append(('my_nick' if d.own_mess else 'other_nick', u"[{}] ".format(d.nick or ''))) + + msg = self.message # needed to generate self.selected_lang + + if self.selected_lang: + markup.append(("msg_lang", u"[{}] ".format(self.selected_lang))) + + # message body + markup.append(msg) + + return markup + +@total_ordering +class OccupantWidget(urwid.WidgetWrap): + + def __init__(self, occupant_data): + self.occupant_data = occupant_data + occupant_data.widgets.add(self) + markup = self._generateMarkup() + super(OccupantWidget, self).__init__(urwid.Text(markup)) + + def __eq__(self, other): + return self.occupant_data.nick == other.occupant_data.nick + + def __lt__(self, other): + return self.occupant_data.nick.lower() < other.occupant_data.nick.lower() + + @property + def parent(self): + return self.mess_data.parent + + def selectable(self): + return True + + def keypress(self, size, key): + return key + def get_cursor_coords(self, size): return 0, 0 - def display_widget(self, size, focus): - render_txt = [] - if not self.is_info: - if self.parent.show_timestamp: - time_format = "%c" if self.timestamp < self.parent.day_change else "%H:%M" # if the message was sent before today, we print the full date - render_txt.append(('date', "[%s]" % time.strftime(time_format, self.timestamp).decode('utf-8'))) - if self.parent.show_short_nick: - render_txt.append(('my_nick' if self.my_mess else 'other_nick', "**" if self.my_mess else "*")) - else: - render_txt.append(('my_nick' if self.my_mess else 'other_nick', "[%s] " % (self.nick or ''))) - render_txt.append(self.message) - txt_widget = urwid.Text(render_txt, align=self.align) - if self.is_info: - return urwid.AttrMap(txt_widget, 'info_msg') - return txt_widget + def render(self, size, focus=False): + # Text widget doesn't render cursor, but we want one + # so we add it here + canvas = urwid.CompositeCanvas(self._w.render(size, focus)) + if focus: + canvas.set_cursor(self.get_cursor_coords(size)) + return canvas + + def _generateMarkup(self): + # TODO: role and affiliation are shown in a Q&D way + # should be more intuitive and themable + o = self.occupant_data + markup = [] + markup.append(('info_msg', '{}{} '.format( + o.role[0].upper(), + o.affiliation[0].upper(), + ))) + markup.append(o.nick) + return markup -class Chat(PrimitivusWidget, QuickChat): +class Chat(PrimitivusWidget, quick_chat.QuickChat): - def __init__(self, host, target, type_=C.CHAT_ONE2ONE, profiles=None): - QuickChat.__init__(self, host, target, type_, profiles=profiles) - self.content = urwid.SimpleListWalker([]) - self.text_list = urwid.ListBox(self.content) - self.chat_widget = urwid.Frame(self.text_list) - self.chat_colums = urwid.Columns([('weight', 8, self.chat_widget)]) + def __init__(self, host, target, type_=C.CHAT_ONE2ONE, occupants=None, subject=None, profiles=None): + quick_chat.QuickChat.__init__(self, host, target, type_, occupants, subject, profiles=profiles) + self.filters = [] # list of filter callbacks to apply + self.mess_walker = urwid.SimpleListWalker([]) + self.mess_widgets = urwid.ListBox(self.mess_walker) + self.chat_widget = urwid.Frame(self.mess_widgets) self.chat_colums = urwid.Columns([('weight', 8, self.chat_widget)]) self.pile = urwid.Pile([self.chat_colums]) PrimitivusWidget.__init__(self, self.pile, self.target) @@ -95,16 +200,28 @@ # we must adapt the behaviour with the type if type_ == C.CHAT_GROUP: if len(self.chat_colums.contents) == 1: - self.occupants_list = sat_widgets.GenericList([], option_type=sat_widgets.ClickableText, on_click=self._occupantsClicked) - self.occupants_panel = sat_widgets.VerticalSeparator(self.occupants_list) + self.occupants_walker = urwid.SimpleListWalker([]) + # TODO: put a real ContactPanel class here, based on FocusWidget ? + self.occupants_widgets = urwid.ListBox(self.occupants_walker) + # FIXME + # , option_type=sat_widgets.ClickableText, on_click=self._occupantsClicked) + self.occupants_panel = sat_widgets.VerticalSeparator(self.occupants_widgets) self._appendOccupantsPanel() + occupants_list = sorted(self.occupants.keys(), key=lambda o:o.lower()) + for occupant in occupants_list: + occupant_data = self.occupants[occupant] + self.occupants_walker.append(OccupantWidget(occupant_data)) + self.host.addListener('presence', self.presenceListener, [profiles]) + # focus marker is a separator indicated last visible message before focus was lost + self.focus_marker = None # link to current marker + self.focus_marker_set = None # True if a new marker has been inserted self.day_change = time.strptime(time.strftime("%a %b %d 00:00:00 %Y")) # struct_time of day changing time self.show_timestamp = True self.show_short_nick = False self.show_title = 1 # 0: clip title; 1: full title; 2: no title - self.subject = None + self.postInit() def keypress(self, size, key): if key == a_key['OCCUPANTS_HIDE']: # user wants to (un)hide the occupants panel @@ -116,11 +233,11 @@ self._appendOccupantsPanel() elif key == a_key['TIMESTAMP_HIDE']: # user wants to (un)hide timestamp self.show_timestamp = not self.show_timestamp - for wid in self.content: + for wid in self.mess_walker: wid._invalidate() elif key == a_key['SHORT_NICKNAME']: # user wants to (not) use short nick self.show_short_nick = not self.show_short_nick - for wid in self.content: + for wid in self.mess_walker: wid._invalidate() elif key == a_key['SUBJECT_SWITCH']: # user wants to (un)hide group's subject or change its apperance if self.subject: @@ -152,6 +269,23 @@ self.host.addMenus(menu, C.MENU_SINGLE, {'jid': full_jid}) return menu + def setFilter(self, args): + """set filtering of messages + + @param args(list[unicode]): filters following syntax "[filter]=[value]" + empty list to clear all filters + only lang=XX is handled for now + """ + del self.filters[:] + if args: + if args[0].startswith("lang="): + lang = args[0][5:].strip() + self.filters.append(lambda mess_data: lang in mess_data.message) + + del self.mess_walker[:] + for message in self.messages.itervalues(): + self.appendMessage(message) + def presenceListener(self, entity, show, priority, statuses, profile): """Update entity's presence status @@ -161,76 +295,90 @@ @param statuses: dict of statuses @param profile: %(doc_profile)s """ - assert self.type == C.CHAT_GROUP - if entity.bare != self.target: - return - self.update(entity) + # FIXME: disable for refactoring, need to be checked and re-enabled + return + # assert self.type == C.CHAT_GROUP + # if entity.bare != self.target: + # return + # self.update(entity) - def update(self, entity=None): - """Update one or all entities. + def createMessage(self, message): + self.appendMessage(message) + + def _user_moved(self, message): + """return true if message is a user left/joined message - @param entity (jid.JID): entity to update + @param message(quick_chat.Message): message to add """ - contact_list = self.host.contact_lists[self.profile] + if message.type != C.MESS_TYPE_INFO: + return False + try: + info_type = message.extra['info_type'] + except KeyError: + return False + else: + return info_type in quick_chat.ROOM_USER_MOVED - if self.type == C.CHAT_ONE2ONE: # only update the chat title - states = self.getEntityStates(self.target) - self.title_dynamic = ' '.join([u'({})'.format(state) for state in states.values()]) - self.host.redraw() - return + def appendMessage(self, message): + """Create a MessageWidget and append it - nicks = list(self.occupants) - if entity is None: # rebuild all the occupants list - values = [] - nicks.sort() - for nick in nicks: - values.append(self._buildOccupantMarkup(jid.newResource(self.target, nick))) - self.occupants_list.changeValues(values) - else: # add, remove or update only one occupant - nick = entity.resource - show = contact_list.getCache(entity, C.PRESENCE_SHOW) - if show == C.PRESENCE_UNAVAILABLE or show is None: - try: - self.occupants_list.deleteValue(nick) - except ValueError: - pass - else: - values = self.occupants_list.getAllValues() - markup = self._buildOccupantMarkup(entity) - if not values: # room has just been created - values = [markup] - else: # add or update the occupant, keep the list sorted - index = 0 - for entry in values: - order = cmp(entry.value if hasattr(entry, 'value') else entry, nick) - if order < 0: - index += 1 - continue - if order > 0: # insert the occupant - values.insert(index, markup) - else: # update an existing occupant - values[index] = markup - break - if index == len(values): # add to the end of the list - values.append(markup) - self.occupants_list.changeValues(values) - self.host.redraw() + Can merge messages together is desirable (e.g.: multiple joined/leave) + @param message(quick_chat.Message): message to add + """ + if self.filters: + if not all([f(message) for f in self.filters]): + return + if self._user_moved(message): + for wid in reversed(self.mess_walker): + # we merge in/out messages if no message was sent meanwhile + if not isinstance(wid, MessageWidget): + continue + if wid.mess_data.type != C.MESS_TYPE_INFO: + break + if wid.info_type in quick_chat.ROOM_USER_MOVED and wid.mess_data.nick == message.nick: + try: + count = wid.reentered_count + except AttributeError: + count = wid.reentered_count = 1 + nick = wid.mess_data.nick + if message.info_type == quick_chat.ROOM_USER_LEFT: + wid.message = _(u"<= {nick} has left the room ({count})").format(nick=nick, count=count) + else: + wid.message = _(u"<=> {nick} re-entered the room ({count})") .format(nick=nick, count=count) + wid.reentered_count+=1 + return - def _buildOccupantMarkup(self, entity): - """Return the option attributes for a MUC occupant. + if ((self.host.selected_widget != self or not self.host.x_notify.hasFocus()) + and self.focus_marker_set is not None): + if not self.focus_marker_set and not self._locked and self.mess_walker: + if self.focus_marker is not None: + self.mess_walker.remove(self.focus_marker) + self.focus_marker = urwid.Divider('—') + self.mess_walker.append(self.focus_marker) + self.focus_marker_set = True + else: + if self.focus_marker_set: + self.focus_marker_set = False - @param nick (unicode): occupant nickname - """ - # TODO: for now it's not a markup but a simple text, the problem is that ListOption is unicode and not urwid.Text - contact_list = self.host.contact_lists[self.profile] - show = contact_list.getCache(entity, C.PRESENCE_SHOW) - states = self.getEntityStates(entity) - nick = entity.resource - show_icon, entity_attr = C.PRESENCE.get(show, (u'', u'default')) # TODO: use entity_attr and return (nick, markup) - text = "%s%s %s" % (u''.join(states.values()), show_icon, nick) - return (nick, text) + if not message.message: + log.error(u"Received an empty message for uid {}".format(message.uid)) + else: + self.mess_walker.append(MessageWidget(message)) + self.mess_widgets.focus_position = len(self.mess_walker) - 1 # scroll down + self.host.redraw() # FIXME: should not be necessary + + def addUser(self, nick): + occupant = super(Chat, self).addUser(nick) + bisect.insort(self.occupants_walker, OccupantWidget(occupant)) + + def removeUser(self, occupant_data): + occupant = super(Chat, self).removeUser(occupant_data) + if occupant is not None: + for widget in occupant.widgets: + self.occupants_walker.remove(widget) def _occupantsClicked(self, list_wid, clicked_wid): + # FIXME: not called anymore after refactoring assert self.type == C.CHAT_GROUP nick = clicked_wid.getValue().value if nick == self.nick: @@ -275,55 +423,32 @@ def setSubject(self, subject, wrap='space'): """Set title for a group chat""" - QuickChat.setSubject(self, subject) - self.subject = subject + quick_chat.QuickChat.setSubject(self, subject) self.subj_wid = urwid.Text(unicode(subject.replace('\n', '|') if wrap == 'clip' else subject), align='left' if wrap == 'clip' else 'center', wrap=wrap) self.chat_widget.header = urwid.AttrMap(self.subj_wid, 'title') self.host.redraw() - def clearHistory(self): - """Clear the content of this chat.""" - del self.content[:] + ## Messages - def afterHistoryPrint(self): + def updateHistory(self, size=C.HISTORY_LIMIT_DEFAULT, search='', profile='@NONE@'): + del self.mess_walker[:] + if search: + self.mess_walker.append(urwid.Text(_(u"Results for searching the globbing pattern: {}").format(search))) + self.mess_walker.append(urwid.Text(_(u"Type ':history <lines>' to reset the chat history").format(search))) + super(Chat, self).updateHistory(size, search, profile) + + def _onHistoryPrinted(self): """Refresh or scroll down the focus after the history is printed""" - if len(self.content): - self.text_list.focus_position = len(self.content) - 1 # scroll down - self.host.redraw() + for message in self.messages.itervalues(): + self.appendMessage(message) + super(Chat, self)._onHistoryPrinted() def onPrivateCreated(self, widget): self.host.contact_lists[widget.profile].specialResourceVisible(widget.target) - def printMessage(self, nick, my_message, message, timestamp, extra=None, profile=C.PROF_KEY_NONE): - """Print message in chat window. - - @param nick (unicode): author nick - @param my_message (boolean): True if profile is the author - @param message (unicode): message content - @param extra (dict): extra data - """ - new_text = ChatText(self, timestamp, nick, my_message, message) - self.content.append(new_text) - QuickChat.printMessage(self, nick, my_message, message, timestamp, extra, profile) - - def printInfo(self, msg, type_='normal', extra=None): - """Print general info - @param msg: message to print - @type_: one of: - normal: general info like "toto has joined the room" - me: "/me" information like "/me clenches his fist" ==> "toto clenches his fist" - @param timestamp (float): number of seconds since epoch - """ - if extra is None: - extra = {} - try: - timestamp = float(extra['timestamp']) - except KeyError: - timestamp = None - _widget = ChatText(self, timestamp, None, False, msg, is_info=True) - self.content.append(_widget) - QuickChat.printInfo(self, msg, type_, extra) + def onSelected(self): + self.focus_marker_set = False def notify(self, contact="somebody", msg=""): """Notify the user of a new message if primitivus doesn't have the focus. @@ -331,12 +456,13 @@ @param contact (unicode): contact who wrote to the users @param msg (unicode): the message that has been received """ + # FIXME: not called anymore after refactoring if msg == "": return - if self.text_list.get_focus()[1] == len(self.content) - 2: + if self.mess_widgets.get_focus()[1] == len(self.mess_walker) - 2: # we don't change focus if user is not at the bottom # as that mean that he is probably watching discussion history - self.text_list.focus_position = len(self.content) - 1 + self.mess_widgets.focus_position = len(self.mess_walker) - 1 self.host.redraw() if not self.host.x_notify.hasFocus(): if self.type == C.CHAT_ONE2ONE: @@ -355,10 +481,11 @@ # MISC EVENTS # def onDelete(self): - QuickChat.onDelete(self) + # FIXME: to be checked after refactoring + quick_chat.QuickChat.onDelete(self) if self.type == C.CHAT_GROUP: self.host.removeListener('presence', self.presenceListener) -quick_widgets.register(QuickChat, Chat) +quick_widgets.register(quick_chat.QuickChat, Chat) quick_widgets.register(quick_games.Tarot, game_tarot.TarotGame)
--- a/frontends/src/primitivus/constants.py Fri Jun 24 22:32:58 2016 +0200 +++ b/frontends/src/primitivus/constants.py Fri Jun 24 22:41:28 2016 +0200 @@ -33,10 +33,13 @@ ('default_focus', 'default,bold', 'default'), ('alert', 'default,underline', 'default'), ('alert_focus', 'default,bold,underline', 'default'), + # Messages ('date', 'light gray', 'default'), ('my_nick', 'dark red,bold', 'default'), ('other_nick', 'dark cyan,bold', 'default'), ('info_msg', 'yellow', 'default', 'bold'), + ('msg_lang', 'dark cyan', 'default'), + ('menubar', 'light gray,bold', 'dark red'), ('menubar_focus', 'light gray,bold', 'dark green'), ('selected_menu', 'light gray,bold', 'dark green'),
--- a/frontends/src/primitivus/contact_list.py Fri Jun 24 22:32:58 2016 +0200 +++ b/frontends/src/primitivus/contact_list.py Fri Jun 24 22:41:28 2016 +0200 @@ -28,13 +28,18 @@ from sat_frontends.tools import jid from sat.core import log as logging log = logging.getLogger(__name__) +from sat_frontends.quick_frontend import quick_widgets class ContactList(PrimitivusWidget, QuickContactList): + PROFILES_MULTIPLE=False + PROFILES_ALLOW_NONE=False signals = ['click','change'] + # FIXME: Only single profile is managed so far - def __init__(self, host, on_click=None, on_change=None, user_data=None, profile=None): - QuickContactList.__init__(self, host, profile) + def __init__(self, host, target, on_click=None, on_change=None, user_data=None, profiles=None): + QuickContactList.__init__(self, host, profiles) + self.contact_list = self.host.contact_lists[self.profile] #we now build the widget self.status_bar = StatusBar(host) @@ -45,8 +50,9 @@ if on_change: urwid.connect_signal(self, 'change', on_change, user_data) - def update(self): + def update(self, entities=None, type_=None, profile=None): """Update display, keep focus""" + # FIXME: full update is done each time, must handle entities, type_ and profile widget, position = self.frame.body.get_focus() self.frame.body = self._buildList() if position: @@ -65,15 +71,22 @@ (key == a_key['FOCUS_DOWN'] and self.frame.focus_position == 'footer')): return key if key == a_key['STATUS_HIDE']: #user wants to (un)hide contacts' statuses - self.show_status = not self.show_status + self.contact_list.show_status = not self.contact_list.show_status self.update() elif key == a_key['DISCONNECTED_HIDE']: #user wants to (un)hide disconnected contacts - self.host.bridge.setParam(C.SHOW_OFFLINE_CONTACTS, C.boolConst(not self.show_disconnected), "General", profile_key=self.profile) + self.host.bridge.setParam(C.SHOW_OFFLINE_CONTACTS, C.boolConst(not self.contact_list.show_disconnected), "General", profile_key=self.profile) elif key == a_key['RESOURCES_HIDE']: #user wants to (un)hide contacts resources - self.showResources(not self.show_resources) + self.contact_list.showResources(not self.contact_list.show_resources) self.update() return super(ContactList, self).keypress(size, key) + # QuickWidget methods + + @staticmethod + def getWidgetHash(target, profiles): + profiles = sorted(profiles) + return tuple(profiles) + # modify the contact list def setFocus(self, text, select=False): @@ -122,7 +135,7 @@ def _groupClicked(self, group_wid): group = group_wid.getValue() - data = self.getGroupData(group) + data = self.contact_list.getGroupData(group) data[C.GROUP_DATA_FOLDED] = not data.setdefault(C.GROUP_DATA_FOLDED, False) self.setFocus(group) self.update() @@ -136,13 +149,10 @@ @param selected: boolean returned by the widget, telling if it is selected """ entity = contact_wid.data - self.removeAlerts(entity, use_bare_jid) + self.contact_list.removeAlerts(entity, use_bare_jid) self.host.modeHint(C.MODE_INSERTION) self._emit('click', entity) - def onNickUpdate(self, entity, new_nick, profile): - self.update() - # Methods to build the widget def _buildEntityWidget(self, entity, keys=None, use_bare_jid=False, with_alert=True, with_show_attr=True, markup_prepend=None, markup_append = None): @@ -163,13 +173,13 @@ """ markup = [] if use_bare_jid: - selected = {entity.bare for entity in self._selected} + selected = {entity.bare for entity in self.contact_list._selected} else: - selected = self._selected + selected = self.contact_list._selected if keys is None: entity_txt = entity else: - cache = self.getCache(entity) + cache = self.contact_list.getCache(entity) for key in keys: if key.startswith('cache_'): entity_txt = cache.get(key[6:]) @@ -181,7 +191,7 @@ entity_txt = entity if with_show_attr: - show = self.getCache(entity, C.PRESENCE_SHOW) + show = self.contact_list.getCache(entity, C.PRESENCE_SHOW) if show is None: show = C.PRESENCE_UNAVAILABLE show_icon, entity_attr = C.PRESENCE.get(show, ('', 'default')) @@ -189,7 +199,7 @@ else: entity_attr = 'default' - alerts_count = self.getAlerts(entity, use_bare_jid=use_bare_jid) + alerts_count = len(self.contact_list.getAlerts(entity, use_bare_jid=use_bare_jid)) if with_alert and alerts_count: entity_attr = 'alert' header = C.ALERT_HEADER % alerts_count @@ -221,22 +231,22 @@ widgets = [] # list of built widgets for entity in entities: - if entity in self._specials or not self.entityToShow(entity): + if entity in self.contact_list._specials or not self.contact_list.entityToShow(entity): continue markup_extra = [] - if self.show_resources: - for resource in self.getCache(entity, C.CONTACT_RESOURCES): + if self.contact_list.show_resources: + for resource in self.contact_list.getCache(entity, C.CONTACT_RESOURCES): resource_disp = ('resource_main' if resource == self.getCache(entity, C.CONTACT_MAIN_RESOURCE) else 'resource', "\n " + resource) markup_extra.append(resource_disp) - if self.show_status: - status = self.getCache(jid.JID('%s/%s' % (entity, resource)), 'status') + if self.contact_list.show_status: + status = self.contact_list.getCache(jid.JID('%s/%s' % (entity, resource)), 'status') status_disp = ('status', "\n " + status) if status else "" markup_extra.append(status_disp) else: - if self.show_status: - status = self.getCache(entity, 'status') + if self.contact_list.show_status: + status = self.contact_list.getCache(entity, 'status') status_disp = ('status', "\n " + status) if status else "" markup_extra.append(status_disp) widget = self._buildEntityWidget(entity, ('cache_nick', 'cache_name', 'node'), use_bare_jid=True, markup_append=markup_extra) @@ -249,7 +259,7 @@ def _buildSpecials(self, content): """Build the special entities""" - specials = list(self._specials) + specials = list(self.contact_list._specials) specials.sort() extra_shown = set() for entity in specials: @@ -258,7 +268,7 @@ content.append(widget) # resources which must be displayed (e.g. MUC private conversations) - extras = [extra for extra in self._special_extras if extra.bare == entity.bare] + extras = [extra for extra in self.contact_list._special_extras if extra.bare == entity.bare] extras.sort() for extra in extras: widget = self._buildEntityWidget(extra, ('resource',), markup_prepend = ' ') @@ -266,7 +276,7 @@ extra_shown.add(extra) # entities which must be visible but not resource of current special entities - for extra in self._special_extras.difference(extra_shown): + for extra in self.contact_list._special_extras.difference(extra_shown): widget = self._buildEntityWidget(extra, ('resource',)) content.append(widget) @@ -275,25 +285,27 @@ content = urwid.SimpleListWalker([]) self._buildSpecials(content) - if self._specials: + if self.contact_list._specials: content.append(urwid.Divider('=')) - groups = list(self._groups) + groups = list(self.contact_list._groups) groups.sort(key=lambda x: x.lower() if x else x) for group in groups: - data = self.getGroupData(group) + data = self.contact_list.getGroupData(group) folded = data.get(C.GROUP_DATA_FOLDED, False) jids = list(data['jids']) - if group is not None and (self.anyEntityToShow(jids) or self.show_empty_groups): + if group is not None and (self.contact_list.anyEntityToShow(jids) or self.contact_list.show_empty_groups): header = '[-]' if not folded else '[+]' widget = sat_widgets.ClickableText(group, header=header + ' ') content.append(widget) urwid.connect_signal(widget, 'click', self._groupClicked) if not folded: self._buildEntities(content, jids) - not_in_roster = set(self._cache).difference(self._roster).difference(self._specials).difference((self.whoami.bare,)) + not_in_roster = set(self.contact_list._cache).difference(self.contact_list._roster).difference(self.contact_list._specials).difference((self.contact_list.whoami.bare,)) if not_in_roster: content.append(urwid.Divider('-')) self._buildEntities(content, not_in_roster) return urwid.ListBox(content) + +quick_widgets.register(QuickContactList, ContactList)
--- a/frontends/src/primitivus/primitivus Fri Jun 24 22:32:58 2016 +0200 +++ b/frontends/src/primitivus/primitivus Fri Jun 24 22:41:28 2016 +0200 @@ -73,7 +73,7 @@ for params, see AdvancedEdit""" nicks = [] for profile, clist in self.host.contact_lists.iteritems(): - for contact in clist.getContacts(): + for contact in clist.selected: chat = self.host.widgets.getWidget(quick_chat.QuickChat, contact, profile) if chat.type != C.CHAT_GROUP: continue @@ -99,12 +99,13 @@ if self.mode == C.MODE_INSERTION: if isinstance(self.host.selected_widget, quick_chat.QuickChat): chat_widget = self.host.selected_widget - self.host.sendMessage(chat_widget.target, - editBar.get_edit_text(), - mess_type = "groupchat" if chat_widget.type == 'group' else "chat", # TODO: put this in QuickChat - errback=lambda failure: self.host.notify(_("Error while sending message ({})").format(failure)), - profile_key=chat_widget.profile - ) + self.host.messageSend( + chat_widget.target, + {'': editBar.get_edit_text()}, # TODO: handle language + mess_type = C.MESS_TYPE_GROUPCHAT if chat_widget.type == C.CHAT_GROUP else C.MESS_TYPE_CHAT, # TODO: put this in QuickChat + errback=lambda failure: self.host.notify(_("Error while sending message ({})").format(failure)), + profile_key=chat_widget.profile + ) editBar.set_edit_text('') elif self.mode == C.MODE_COMMAND: self.commandHandler() @@ -141,19 +142,22 @@ limit = int(args[0]) except (IndexError, ValueError): limit = 50 - widget.clearHistory() - if limit > 0: - widget.historyPrint(size=limit, profile=widget.profile) + widget.updateHistory(size=limit, profile=widget.profile) elif command == 'search': widget = self.host.selected_widget if isinstance(widget, quick_chat.QuickChat): pattern = " ".join(args) if not pattern: self.host.notif_bar.addMessage(D_("Please specify the globbing pattern to search for")) - widget.clearHistory() - widget.printInfo(D_("Results for searching the globbing pattern: %s") % pattern, extra={'timestamp': 0}) - widget.historyPrint(size=C.HISTORY_LIMIT_NONE, search=pattern, profile=widget.profile) - widget.printInfo(D_("Type ':history <lines>' to reset the chat history")) + else: + widget.updateHistory(size=C.HISTORY_LIMIT_NONE, search=pattern, profile=widget.profile) + elif command == 'filter': + # FIXME: filter is now only for current widget, + # need to be able to set it globally or per widget + widget = self.host.selected_widget + # FIXME: Q&D way, need to be more generic + if isinstance(widget, quick_chat.QuickChat): + widget.setFilter(args) else: return self.set_edit_text('') @@ -533,11 +537,6 @@ self.main_widget = PrimitivusTopWidget(self.center_part, self.menu_roller, self.notif_bar, self.editBar) return self.main_widget - def addContactList(self, profile): - contact_list = ContactList(self, on_click=self.contactSelected, on_change=lambda w: self.redraw(), profile=profile) - self.contact_lists_pile.contents.append((contact_list, ('weight', 1))) - return contact_list - def plugging_profiles(self): self.loop.widget = self._buildMainWidget() self.redraw() @@ -549,6 +548,12 @@ else: del self._early_popup + def profilePlugged(self, profile): + QuickApp.profilePlugged(self, profile) + contact_list = self.widgets.getOrCreateWidget(ContactList, None, on_new_widget=None, on_click=self.contactSelected, on_change=lambda w: self.redraw(), profile=profile) + self.contact_lists_pile.contents.append((contact_list, ('weight', 1))) + return contact_list + def isHidden(self): """Tells if the frontend window is hidden. @@ -607,7 +612,8 @@ self.redraw() def newWidget(self, widget): - self.selectWidget(widget) + if self.selected_widget is None: + self.selectWidget(widget) def selectWidget(self, widget): """Display a widget if possible, @@ -623,11 +629,16 @@ except KeyError: log.debug("No menu to delete") self.selected_widget = widget + try: + onSelected = self.selected_widget.onSelected + except AttributeError: + pass + else: + onSelected() self._visible_widgets = set([widget]) # XXX: we can only have one widget visible at the time for now - for contact_list in self.contact_lists.itervalues(): - contact_list.unselectAll() + self.contact_lists.select(None) - for wid in self.visible_widgets: + for wid in self.visible_widgets: # FIXME: check if widgets.getWidgets is not more appropriate if isinstance(wid, Chat): contact_list = self.contact_lists[wid.profile] contact_list.select(wid.target) @@ -790,9 +801,12 @@ log.error (_("FIXME FIXME FIXME: type [%s] not implemented") % type_) raise NotImplementedError - def roomJoinedHandler(self, room_jid_s, room_nicks, user_nick, profile): - super(PrimitivusApp, self).roomJoinedHandler(room_jid_s, room_nicks, user_nick, profile) - self.contact_lists[profile].setFocus(jid.JID(room_jid_s), True) + def roomJoinedHandler(self, room_jid_s, room_nicks, user_nick, subject, profile): + super(PrimitivusApp, self).roomJoinedHandler(room_jid_s, room_nicks, user_nick, subject, profile) + # if self.selected_widget is None: + # for contact_list in self.widgets.getWidgets(ContactList): + # if profile in contact_list.profiles: + # contact_list.setFocus(jid.JID(room_jid_s), True) def progressStartedHandler(self, pid, metadata, profile): super(PrimitivusApp, self).progressStartedHandler(pid, metadata, profile) @@ -811,7 +825,7 @@ def onJoinRoom(self, button, edit): self.removePopUp() room_jid = jid.JID(edit.get_edit_text()) - self.bridge.joinMUC(room_jid, self.profiles[self.current_profile].whoami.node, {}, self.current_profile, callback=lambda dummy: None, errback=self.dialogFailure) + self.bridge.mucJoin(room_jid, self.profiles[self.current_profile].whoami.node, {}, self.current_profile, callback=lambda dummy: None, errback=self.dialogFailure) #MENU EVENTS# def onConnectRequest(self, menu): @@ -851,7 +865,11 @@ #MISC CALLBACKS# def setPresenceStatus(self, show='', status=None, profile=C.PROF_KEY_NONE): - self.contact_lists[profile].status_bar.setPresenceStatus(show, status) + contact_list_wid = self.widgets.getWidget(ContactList, profiles=profile) + if contact_list_wid is not None: + contact_list_wid.status_bar.setPresenceStatus(show, status) + else: + log.warning(u"No ContactList widget found for profile {}".format(profile)) sat = PrimitivusApp() sat.start()
--- a/frontends/src/quick_frontend/constants.py Fri Jun 24 22:32:58 2016 +0200 +++ b/frontends/src/quick_frontend/constants.py Fri Jun 24 22:41:28 2016 +0200 @@ -47,8 +47,10 @@ CONTACT_MAIN_RESOURCE = 'main_resource' CONTACT_SPECIAL = 'special' CONTACT_SPECIAL_GROUP = 'group' # group chat special entity - CONTACT_SPECIAL_ALLOWED = (CONTACT_SPECIAL_GROUP,) # set of allowed values for special flag - CONTACT_DATA_FORBIDDEN = {CONTACT_GROUPS, CONTACT_RESOURCES, CONTACT_MAIN_RESOURCE} # set of forbidden names for contact data + CONTACT_SELECTED = 'selected' + CONTACT_PROFILE = 'profile' # used in handler to track where the contact is coming from + CONTACT_SPECIAL_ALLOWED = (CONTACT_SPECIAL_GROUP,) # allowed values for special flag + CONTACT_DATA_FORBIDDEN = {CONTACT_GROUPS, CONTACT_RESOURCES, CONTACT_MAIN_RESOURCE, CONTACT_SELECTED, CONTACT_PROFILE} # set of forbidden names for contact data # Chats CHAT_ONE2ONE = 'one2one' @@ -61,6 +63,10 @@ "paused": u"⦷" } + # Alerts + ALERT_MESSAGE = "MESSAGE" # New message received + ALERT_NICK = "NICK" # our nickname was mentionned + # Blogs ENTRY_MODE_TEXT = "text" ENTRY_MODE_RICH = "rich" @@ -73,4 +79,11 @@ WIDGET_RAISE = 'RAISE' WIDGET_RECREATE = 'RECREATE' + # Updates (generic) + UPDATE_DELETE = 'DELETE' + UPDATE_MODIFY = 'MODIFY' + UPDATE_ADD = 'ADD' + UPDATE_SELECTION = 'SELECTION' + UPDATE_STRUCTURE = 'STRUCTURE' # high level update (i.e. not item level but organisation of items) + LISTENERS = {'avatar', 'nick', 'presence', 'profilePlugged', 'disconnect', 'gotMenus', 'menu'}
--- a/frontends/src/quick_frontend/quick_app.py Fri Jun 24 22:32:58 2016 +0200 +++ b/frontends/src/quick_frontend/quick_app.py Fri Jun 24 22:41:28 2016 +0200 @@ -29,6 +29,7 @@ from sat_frontends.quick_frontend import quick_menus from sat_frontends.quick_frontend import quick_blog from sat_frontends.quick_frontend import quick_chat, quick_games +from sat_frontends.quick_frontend import quick_contact_list from sat_frontends.quick_frontend.constants import Const as C import sys @@ -95,8 +96,7 @@ def _plug_profile_gotCachedValues(self, cached_values): # add the contact list and its listener - contact_list = self.host.addContactList(self.profile) - self.host.contact_lists[self.profile] = contact_list + contact_list = self.host.contact_lists.addProfile(self.profile) for entity, data in cached_values.iteritems(): for key, value in data.iteritems(): @@ -122,13 +122,6 @@ #Now we open the MUC window where we already are: for room_args in rooms_args: self.host.roomJoinedHandler(*room_args, profile=self.profile) - - self.bridge.getRoomsSubjects(self.profile, callback=self._plug_profile_gotRoomsSubjects) - - def _plug_profile_gotRoomsSubjects(self, subjects_args): - for subject_args in subjects_args: - self.host.roomNewSubjectHandler(*subject_args, profile=self.profile) - #Presence must be requested after rooms are filled self.host.bridge.getPresenceStatuses(self.profile, callback=self._plug_profile_gotPresences) @@ -192,8 +185,7 @@ # remove the contact list and its listener host = self._profiles[profile].host - host.contact_lists[profile].onDelete() - del host.contact_lists[profile] + host.contact_lists[profile].unplug() del self._profiles[profile] @@ -216,9 +208,10 @@ self.menus = quick_menus.QuickMenusManager(self) ProfileManager.host = self self.profiles = ProfilesManager() + self._plugs_in_progress = set() # profiles currently being plugged, used to (un)lock contact list updates self.ready_profiles = set() # profiles which are connected and ready self.signals_cache = {} # used to keep signal received between start of plug_profile and when the profile is actualy ready - self.contact_lists = {} + self.contact_lists = quick_contact_list.QuickContactListHandler(self) self.widgets = quick_widgets.QuickWidgetsManager(self) if check_options is not None: self.options = check_options() @@ -248,7 +241,7 @@ self.registerSignal("disconnected") self.registerSignal("actionNew") self.registerSignal("newContact") - self.registerSignal("newMessage") + self.registerSignal("messageNew") self.registerSignal("newAlert") self.registerSignal("presenceUpdate") self.registerSignal("subscribe") @@ -263,8 +256,6 @@ self.registerSignal("actionResultExt", self.actionResultHandler) self.registerSignal("roomJoined", iface="plugin") self.registerSignal("roomLeft", iface="plugin") - self.registerSignal("roomUserJoined", iface="plugin") - self.registerSignal("roomUserLeft", iface="plugin") self.registerSignal("roomUserChangedNick", iface="plugin") self.registerSignal("roomNewSubject", iface="plugin") self.registerSignal("chatStateReceived", iface="plugin") @@ -296,7 +287,8 @@ @property def alerts_count(self): """Count the over whole alerts for all contact lists""" - return sum([sum(clist._alerts.values()) for clist in self.contact_lists.values()]) + # FIXME + # return sum([sum(clist._alerts.values()) for clist in self.contact_lists.values()]) def registerSignal(self, function_name, handler=None, iface="core", with_profile=True): """Register a handler for a signal @@ -401,6 +393,7 @@ @param profile(unicode): %(doc_profile)s """ + self._plugs_in_progress.remove(profile) self.ready_profiles.add(profile) # profile is ready, we can call send signals that where is cache @@ -410,6 +403,8 @@ handler(*args, **kwargs) self.callListeners('profilePlugged', profile=profile) + if not self._plugs_in_progress: + self.contact_lists.lockUpdate(False) def asyncConnect(self, profile, callback=None, errback=None): if not callback: @@ -428,6 +423,8 @@ @param profiles: list of valid profile names """ + self.contact_lists.lockUpdate() + self._plugs_in_progress.update(profiles) self.plugging_profiles() for profile in profiles: self.profiles.plug(profile) @@ -448,14 +445,6 @@ def clear_profile(self): self.profiles.clear() - def addContactList(self, profile): - """Method to subclass to add a contact list widget - - will be called on each profile session build - @return: a ContactList widget - """ - return NotImplementedError - def newWidget(self, widget): raise NotImplementedError @@ -473,7 +462,7 @@ def disconnectedHandler(self, profile): """called when the connection is closed""" log.debug(_("Disconnected")) - self.contact_lists[profile].clearContacts() + self.contact_lists[profile].disconnect() self.setPresenceStatus(C.PRESENCE_UNAVAILABLE, '', profile=profile) def actionNewHandler(self, action_data, id_, security_limit, profile): @@ -481,22 +470,19 @@ def newContactHandler(self, jid_s, attributes, groups, profile): entity = jid.JID(jid_s) - _groups = list(groups) - self.contact_lists[profile].setContact(entity, _groups, attributes, in_roster=True) + groups = list(groups) + self.contact_lists[profile].setContact(entity, groups, attributes, in_roster=True) - def newMessageHandler(self, from_jid_s, msg, type_, to_jid_s, extra, profile): + def messageNewHandler(self, uid, timestamp, from_jid_s, to_jid_s, msg, subject, type_, extra, profile): from_jid = jid.JID(from_jid_s) to_jid = jid.JID(to_jid_s) - if not self.trigger.point("newMessageTrigger", from_jid, msg, type_, to_jid, extra, profile=profile): + if not self.trigger.point("messageNewTrigger", uid, timestamp, from_jid, to_jid, msg, subject, type_, extra, profile=profile): return from_me = from_jid.bare == self.profiles[profile].whoami.bare target = to_jid if from_me else from_jid - - chat_type = C.CHAT_GROUP if type_ == C.MESS_TYPE_GROUPCHAT else C.CHAT_ONE2ONE contact_list = self.contact_lists[profile] - - chat_widget = self.widgets.getOrCreateWidget(quick_chat.QuickChat, target, type_=chat_type, on_new_widget=None, profile=profile) + chat_widget = self.widgets.getOrCreateWidget(quick_chat.QuickChat, target, type_=C.CHAT_ONE2ONE, on_new_widget=None, profile=profile) self.current_action_ids = set() # FIXME: to be removed self.current_action_ids_cb = {} # FIXME: to be removed @@ -507,7 +493,8 @@ contact_list.setContact(from_jid) # we display the message in the widget - chat_widget.newMessage(from_jid, target, msg, type_, extra, profile) + + chat_widget.messageNew(uid, timestamp, from_jid, target, msg, subject, type_, extra, profile) # ContactList alert if not from_me: @@ -516,22 +503,26 @@ if isinstance(widget, quick_chat.QuickChat) and widget.manageMessage(from_jid, type_): visible = True break - if visible: + if visible: # FIXME: à virer gof: if self.isHidden(): # the window is hidden self.updateAlertsCounter(extra_inc=1) else: contact_list.addAlert(from_jid.bare if type_ == C.MESS_TYPE_GROUPCHAT else from_jid) - def sendMessage(self, to_jid, message, subject='', mess_type="auto", extra={}, callback=None, errback=None, profile_key=C.PROF_KEY_NONE): + def messageSend(self, to_jid, message, subject=None, mess_type="auto", extra=None, callback=None, errback=None, profile_key=C.PROF_KEY_NONE): + if subject is None: + subject = {} + if extra is None: + extra = {} if callback is None: callback = lambda dummy=None: None # FIXME: optional argument is here because pyjamas doesn't support callback without arg with json proxy if errback is None: errback = lambda failure: self.showDialog(failure.fullname, failure.message, "error") - if not self.trigger.point("sendMessageTrigger", to_jid, message, subject, mess_type, extra, callback, errback, profile_key=profile_key): + if not self.trigger.point("messageSendTrigger", to_jid, message, subject, mess_type, extra, callback, errback, profile_key=profile_key): return - self.bridge.sendMessage(unicode(to_jid), message, subject, mess_type, extra, profile_key, callback=callback, errback=errback) + self.bridge.messageSend(unicode(to_jid), message, subject, mess_type, extra, profile_key, callback=callback, errback=errback) def newAlertHandler(self, msg, title, alert_type, profile): assert alert_type in ['INFO', 'ERROR'] @@ -557,14 +548,14 @@ self.callListeners('presence', entity, show, priority, statuses, profile=profile) - def roomJoinedHandler(self, room_jid_s, room_nicks, user_nick, profile): + def roomJoinedHandler(self, room_jid_s, occupants, user_nick, subject, profile): """Called when a MUC room is joined""" - log.debug(u"Room [%(room_jid)s] joined by %(profile)s, users presents:%(users)s" % {'room_jid': room_jid_s, 'profile': profile, 'users': room_nicks}) + log.debug(u"Room [{room_jid}] joined by {profile}, users presents:{users}".format(room_jid=room_jid_s, profile=profile, users=occupants.keys())) room_jid = jid.JID(room_jid_s) - chat_widget = self.widgets.getOrCreateWidget(quick_chat.QuickChat, room_jid, type_=C.CHAT_GROUP, profile=profile) + chat_widget = self.widgets.getOrCreateWidget(quick_chat.QuickChat, room_jid, type_=C.CHAT_GROUP, occupants=occupants, subject=subject, profile=profile) chat_widget.setUserNick(unicode(user_nick)) self.contact_lists[profile].setSpecial(room_jid, C.CONTACT_SPECIAL_GROUP) - chat_widget.update() + # chat_widget.update() def roomLeftHandler(self, room_jid_s, profile): """Called when a MUC room is left""" @@ -575,20 +566,6 @@ self.widgets.deleteWidget(chat_widget) self.contact_lists[profile].removeContact(room_jid) - def roomUserJoinedHandler(self, room_jid_s, user_nick, user_data, profile): - """Called when an user joined a MUC room""" - room_jid = jid.JID(room_jid_s) - chat_widget = self.widgets.getOrCreateWidget(quick_chat.QuickChat, room_jid, type_=C.CHAT_GROUP, profile=profile) - chat_widget.addUser(user_nick) - log.debug(u"user [%(user_nick)s] joined room [%(room_jid)s]" % {'user_nick': user_nick, 'room_jid': room_jid}) - - def roomUserLeftHandler(self, room_jid_s, user_nick, user_data, profile): - """Called when an user joined a MUC room""" - room_jid = jid.JID(room_jid_s) - chat_widget = self.widgets.getOrCreateWidget(quick_chat.QuickChat, room_jid, type_=C.CHAT_GROUP, profile=profile) - chat_widget.removeUser(user_nick) - log.debug(u"user [%(user_nick)s] left room [%(room_jid)s]" % {'user_nick': user_nick, 'room_jid': room_jid}) - def roomUserChangedNickHandler(self, room_jid_s, old_nick, new_nick, profile): """Called when an user joined a MUC room""" room_jid = jid.JID(room_jid_s) @@ -610,20 +587,20 @@ @param state (unicode): new state @param profile (unicode): current profile """ - log.debug(_(u"Received new chat state {} from {} [{}]").format(state, from_jid_s, profile)) - from_jid = jid.JID(from_jid_s) if from_jid_s != C.ENTITY_ALL else C.ENTITY_ALL - contact_list = self.contact_lists[profile] - for widget in self.widgets.getWidgets(quick_chat.QuickChat): - if profile != widget.profile: - continue - to_display = C.USER_CHAT_STATES[state] if (state and widget.type == C.CHAT_GROUP) else state - if widget.type == C.CHAT_GROUP and from_jid_s == C.ENTITY_ALL: - for occupant in [jid.newResource(widget.target, nick) for nick in widget.occupants]: - contact_list.setCache(occupant, 'chat_state', to_display) - widget.update(occupant) - elif from_jid.bare == widget.target.bare: # roster contact or MUC occupant - contact_list.setCache(from_jid, 'chat_state', to_display) - widget.update(from_jid) + # log.debug(_(u"Received new chat state {} from {} [{}]").format(state, from_jid_s, profile)) + # from_jid = jid.JID(from_jid_s) if from_jid_s != C.ENTITY_ALL else C.ENTITY_ALL + # contact_list = self.contact_lists[profile] + # for widget in self.widgets.getWidgets(quick_chat.QuickChat): + # if profile != widget.profile: + # continue + # to_display = C.USER_CHAT_STATES[state] if (state and widget.type == C.CHAT_GROUP) else state + # if widget.type == C.CHAT_GROUP and from_jid_s == C.ENTITY_ALL: + # for occupant in [jid.newResource(widget.target, nick) for nick in widget.occupants]: + # contact_list.setCache(occupant, 'chat_state', to_display) + # widget.update(occupant) + # elif from_jid.bare == widget.target.bare: # roster contact or MUC occupant + # contact_list.setCache(from_jid, 'chat_state', to_display) + # widget.update(from_jid) def psEventHandler(self, category, service_s, node, event_type, data, profile): """Called when a PubSub event is received. @@ -730,7 +707,7 @@ def contactDeletedHandler(self, jid_s, profile): target = jid.JID(jid_s) - self.contact_lists[profile].removeContact(target, in_roster=True) + self.contact_lists[profile].removeContact(target) def entityDataUpdatedHandler(self, entity_s, key, value, profile): entity = jid.JID(entity_s)
--- a/frontends/src/quick_frontend/quick_chat.py Fri Jun 24 22:32:58 2016 +0200 +++ b/frontends/src/quick_frontend/quick_chat.py Fri Jun 24 22:41:28 2016 +0200 @@ -20,12 +20,17 @@ from sat.core.i18n import _ from sat.core.log import getLogger log = getLogger(__name__) -from sat_frontends.tools import jid +from sat.core import exceptions from sat_frontends.quick_frontend import quick_widgets from sat_frontends.quick_frontend.constants import Const as C from collections import OrderedDict -from datetime import datetime -from time import time +from sat_frontends.tools import jid + +ROOM_USER_JOINED = 'ROOM_USER_JOINED' +ROOM_USER_LEFT = 'ROOM_USER_LEFT' +ROOM_USER_MOVED = (ROOM_USER_JOINED, ROOM_USER_LEFT) + +# from datetime import datetime try: # FIXME: to be removed when an acceptable solution is here @@ -33,37 +38,117 @@ except (TypeError, AttributeError): # Error raised is not the same depending on pyjsbuild options unicode = str +# FIXME: day_format need to be settable (i18n) + +class Message(object): + """Message metadata""" + + def __init__(self, parent, uid, timestamp, from_jid, to_jid, msg, subject, type_, extra, profile): + self.parent = parent + self.profile = profile + self.uid = uid + self.timestamp = timestamp + self.from_jid = from_jid + self.to_jid = to_jid + self.message = msg + self.subject = subject + self.type = type_ + self.extra = extra + self.nick = self.getNick(from_jid) + # own_mess is True if message was sent by profile's jid + self.own_mess = (from_jid.resource == self.parent.nick) if self.parent.type == C.CHAT_GROUP else (from_jid.bare == self.host.profiles[profile].whoami.bare) + self.widgets = set() # widgets linked to this message + + @property + def host(self): + return self.parent.host + + @property + def info_type(self): + return self.extra.get('info_type') + + def getNick(self, entity): + """Return nick of an entity when possible""" + contact_list = self.host.contact_lists[self.profile] + if self.type == C.MESS_TYPE_INFO and self.info_type in ROOM_USER_MOVED: + try: + return self.extra['user_nick'] + except KeyError: + log.error(u"extra data is missing user nick for uid {}".format(self.uid)) + return "" + if self.parent.type == C.CHAT_GROUP or entity in contact_list.getSpecialExtras(C.CONTACT_SPECIAL_GROUP): + return entity.resource or "" + if entity.bare in contact_list: + return contact_list.getCache(entity, 'nick') or contact_list.getCache(entity, 'name') or entity.node or entity + return entity.node or entity + + +class Occupant(object): + """Occupant metadata""" + + def __init__(self, parent, data, profile): + self.parent = parent + self.profile = profile + self.nick = data['nick'] + self.entity = data.get('entity') + if not self.entity: + self.entity = jid.JID(u"{}/{}".format(parent.target.bare, self.nick)), + self.affiliation = data['affiliation'] + self.role = data['role'] + self.widgets = set() # widgets linked to this occupant + + @property + def host(self): + return self.parent.host + class QuickChat(quick_widgets.QuickWidget): visible_states = ['chat_state'] - def __init__(self, host, target, type_=C.CHAT_ONE2ONE, profiles=None): + def __init__(self, host, target, type_=C.CHAT_ONE2ONE, occupants=None, subject=None, profiles=None): """ @param type_: can be C.CHAT_ONE2ONE for single conversation or C.CHAT_GROUP for chat à la IRC """ + self.lang = '' # default language to use for messages quick_widgets.QuickWidget.__init__(self, host, target, profiles=profiles) + self._locked = False # True when we are waiting for history/search + # messageNew signals are cached when locked + self._cache = [] assert type_ in (C.CHAT_ONE2ONE, C.CHAT_GROUP) - if type_ == C.CHAT_GROUP and target.resource: - raise ValueError("A group chat entity can't have a resource") self.current_target = target self.type = type_ - self.id = "" # FIXME: to be removed - self.nick = None + if type_ == C.CHAT_GROUP: + if target.resource: + raise exceptions.InternalError(u"a group chat entity can't have a resource") + self.nick = None + self.occupants = {} + self.setOccupants(occupants) + else: + if occupants is not None: + raise exceptions.InternalError(u"only group chat can have occupants") + self.messages = OrderedDict() # key: uid, value: Message instance self.games = {} # key=game name (unicode), value=instance of quick_games.RoomGame + self.subject = subject - if type_ == C.CHAT_ONE2ONE: - self.historyPrint(profile=self.profile) + def postInit(self): + """Method to be called by frontend after widget is initialised - # FIXME: has been introduced to temporarily fix http://bugs.goffi.org/show_bug.cgi?id=12 - self.initialised = False + handle the display of history and subject + """ + self.historyPrint(profile=self.profile) + if self.subject is not None: + self.setSubject(self.subject) + + ## Widget management ## def __str__(self): return u"Chat Widget [target: {}, type: {}, profile: {}]".format(self.target, self.type, self.profile) @staticmethod - def getWidgetHash(target, profile): - return (unicode(profile), target.bare) + def getWidgetHash(target, profiles): + profile = profiles[0] + return (profile, target.bare) @staticmethod def getPrivateHash(target, profile): @@ -78,112 +163,6 @@ if target.resource: self.current_target = target # FIXME: tmp, must use resource priority throught contactList instead - @property - def target(self): - if self.type == C.CHAT_GROUP: - return self.current_target.bare - return self.current_target - - @property - def occupants(self): - """Return the occupants of a group chat (nicknames). - - @return: set(unicode) - """ - if self.type != C.CHAT_GROUP: - return set() - contact_list = self.host.contact_lists[self.profile] - return contact_list.getCache(self.target, C.CONTACT_RESOURCES).keys() - - def manageMessage(self, entity, mess_type): - """Tell if this chat widget manage an entity and message type couple - - @param entity (jid.JID): (full) jid of the sending entity - @param mess_type (str): message type as given by newMessage - @return (bool): True if this Chat Widget manage this couple - """ - if self.type == C.CHAT_GROUP: - if mess_type == C.MESS_TYPE_GROUPCHAT and self.target == entity.bare: - return True - else: - if mess_type != C.MESS_TYPE_GROUPCHAT and entity in self.targets: - return True - return False - - def addUser(self, nick): - """Add user if it is not in the group list""" - if not self.initialised: - return # FIXME: tmp fix for bug 12, do not flood the room with the messages when we've just entered it - self.printInfo("=> %s has joined the room" % nick) - - def removeUser(self, nick): - """Remove a user from the group list""" - self.printInfo("<= %s has left the room" % nick) - - def setUserNick(self, nick): - """Set the nick of the user, usefull for e.g. change the color of the user""" - self.nick = nick - - def changeUserNick(self, old_nick, new_nick): - """Change nick of a user in group list""" - self.printInfo("%s is now known as %s" % (old_nick, new_nick)) - - def setSubject(self, subject): - """Set title for a group chat""" - log.debug(_("Setting subject to %s") % subject) - if self.type != C.CHAT_GROUP: - log.error (_("[INTERNAL] trying to set subject for a non group chat window")) - raise Exception("INTERNAL ERROR") #TODO: raise proper Exception here - - def afterHistoryPrint(self): - """Refresh or scroll down the focus after the history is printed""" - pass - - def historyPrint(self, size=C.HISTORY_LIMIT_DEFAULT, search='', profile='@NONE@'): - """Print the current history - @param size (int): number of messages - @param search (str): pattern to filter the history results - @param profile (str): %(doc_profile)s - """ - log_msg = _(u"now we print the history") - if size != C.HISTORY_LIMIT_DEFAULT: - log_msg += _(u" (%d messages)" % size) - log.debug(log_msg) - - target = self.target.bare - - def onHistory(history): - self.initialised = True # FIXME: tmp fix for bug 12 - day_format = "%A, %d %b %Y" # to display the day change - previous_day = datetime.now().strftime(day_format) - for line in history: - timestamp, from_jid, to_jid, message, type_, extra = line # FIXME: extra is unused ! - if ((self.type == C.CHAT_GROUP and type_ != C.MESS_TYPE_GROUPCHAT) or - (self.type == C.CHAT_ONE2ONE and type_ == C.MESS_TYPE_GROUPCHAT)): - continue - message_day = datetime.fromtimestamp(float(timestamp or time())).strftime(day_format) - if previous_day != message_day: - self.printDayChange(message_day) - previous_day = message_day - extra["timestamp"] = timestamp - self.newMessage(jid.JID(from_jid), target, message, type_, extra, profile) - self.afterHistoryPrint() - - def onHistoryError(err): - log.error(_("Can't get history")) - - self.initialised = False # FIXME: tmp fix for bug 12, here needed for :history and :search commands - self.host.bridge.getHistory(unicode(self.host.profiles[profile].whoami.bare), unicode(target), size, True, search, profile, callback=onHistory, errback=onHistoryError) - - def _get_nick(self, entity): - """Return nick of this entity when possible""" - contact_list = self.host.contact_lists[self.profile] - if self.type == C.CHAT_GROUP or entity in contact_list.getSpecialExtras(C.CONTACT_SPECIAL_GROUP): - return entity.resource or "" - if entity.bare in contact_list: - return contact_list.getCache(entity, 'nick') or contact_list.getCache(entity, 'name') or entity.node or entity - return entity.node or entity - def onPrivateCreated(self, widget): """Method called when a new widget for private conversation (MUC) is created""" raise NotImplementedError @@ -195,59 +174,171 @@ """ return self.host.widgets.getOrCreateWidget(QuickChat, entity, type_=C.CHAT_ONE2ONE, force_hash=self.getPrivateHash(self.profile, entity), on_new_widget=self.onPrivateCreated, profile=self.profile) # we force hash to have a new widget, not this one again - def newMessage(self, from_jid, target, msg, type_, extra, profile): - if self.type == C.CHAT_GROUP and target.resource and type_ != C.MESS_TYPE_GROUPCHAT: - # we have a private message, we forward it to a private conversation widget - chat_widget = self.getOrCreatePrivateWidget(target) - chat_widget.newMessage(from_jid, target, msg, type_, extra, profile) - return + @property + def target(self): + if self.type == C.CHAT_GROUP: + return self.current_target.bare + return self.current_target + + ## occupants ## + + def setOccupants(self, occupants): + """set the whole list of occupants""" + assert len(self.occupants) == 0 + for nick, data in occupants.iteritems(): + self.occupants[nick] = Occupant( + self, + data, + self.profile + ) + + def addUser(self, occupant_data): + """Add user if it is not in the group list""" + occupant = Occupant( + self, + occupant_data, + self.profile + ) + self.occupants[occupant.nick] = occupant + return occupant + + def removeUser(self, occupant_data): + """Remove a user from the group list""" + nick = occupant_data['nick'] try: - timestamp = float(extra['timestamp']) + occupant = self.occupants.pop(nick) except KeyError: - timestamp = None + log.warning(u"Trying to remove an unknown occupant: {}".format(nick)) + else: + return occupant + + def setUserNick(self, nick): + """Set the nick of the user, usefull for e.g. change the color of the user""" + self.nick = nick - if not self.initialised and self.type == C.CHAT_ONE2ONE: - return # FIXME: tmp fix for bug 12, do not display the first one2one message which is already in the local history + def changeUserNick(self, old_nick, new_nick): + """Change nick of a user in group list""" + self.printInfo("%s is now known as %s" % (old_nick, new_nick)) + + ## Messages ## + + def manageMessage(self, entity, mess_type): + """Tell if this chat widget manage an entity and message type couple - if type_ == C.MESS_TYPE_INFO: - self.printInfo(msg, extra=extra) + @param entity (jid.JID): (full) jid of the sending entity + @param mess_type (str): message type as given by messageNew + @return (bool): True if this Chat Widget manage this couple + """ + if self.type == C.CHAT_GROUP: + if mess_type in (C.MESS_TYPE_GROUPCHAT, C.MESS_TYPE_INFO) and self.target == entity.bare: + return True else: - self.initialised = True # FIXME: tmp fix for bug 12, do not discard any message from now + if mess_type != C.MESS_TYPE_GROUPCHAT and entity in self.targets: + return True + return False + + def updateHistory(self, size=C.HISTORY_LIMIT_DEFAULT, search='', profile='@NONE@'): + """Called when history need to be recreated + + Remove all message from history then call historyPrint + Must probably be overriden by frontend to clear widget + @param size (int): number of messages + @param search (str): pattern to filter the history results + @param profile (str): %(doc_profile)s + """ + self._locked = True + self.messages.clear() + self.historyPrint(size, search, profile) + + def _onHistoryPrinted(self): + """Method called when history is printed (or failed) - nick = self._get_nick(from_jid) - if msg.startswith('/me '): - self.printInfo('* %s %s' % (nick, msg[4:]), type_='me', extra=extra) - else: - # my_message is True if message comes from local user - my_message = (from_jid.resource == self.nick) if self.type == C.CHAT_GROUP else (from_jid.bare == self.host.profiles[profile].whoami.bare) - self.printMessage(nick, my_message, msg, timestamp, extra, profile) - if timestamp: - self.afterHistoryPrint() + unlock the widget, and can be used to refresh or scroll down + the focus after the history is printed + """ + self._locked = False + for data in self._cache: + self.messageNew(*data) + + def historyPrint(self, size=C.HISTORY_LIMIT_DEFAULT, search='', profile='@NONE@'): + """Print the current history - def printMessage(self, nick, my_message, message, timestamp, extra=None, profile=C.PROF_KEY_NONE): - """Print message in chat window. + @param size (int): number of messages + @param search (str): pattern to filter the history results + @param profile (str): %(doc_profile)s + """ + if size == 0: + log.debug(u"Empty history requested, skipping") + self._onHistoryPrinted() + return + log_msg = _(u"now we print the history") + if size != C.HISTORY_LIMIT_DEFAULT: + log_msg += _(u" ({} messages)".format(size)) + log.debug(log_msg) + + target = self.target.bare + + def _historyGetCb(history): + # day_format = "%A, %d %b %Y" # to display the day change + # previous_day = datetime.now().strftime(day_format) + # message_day = datetime.fromtimestamp(timestamp).strftime(self.day_format) + # if previous_day != message_day: + # self.printDayChange(message_day) + # previous_day = message_day + for data in history: + uid, timestamp, from_jid, to_jid, message, subject, type_, extra = data + from_jid = jid.JID(from_jid) + to_jid = jid.JID(to_jid) + # if ((self.type == C.CHAT_GROUP and type_ != C.MESS_TYPE_GROUPCHAT) or + # (self.type == C.CHAT_ONE2ONE and type_ == C.MESS_TYPE_GROUPCHAT)): + # continue + self.messages[uid] = Message(self, uid, timestamp, from_jid, to_jid, message, subject, type_, extra, profile) + self._onHistoryPrinted() - @param nick (unicode): author nick - @param my_message (boolean): True if profile is the author - @param message (unicode): message content - @param extra (dict): extra data - """ - if not timestamp: - # XXX: do not send notifications for each line of the history being displayed - # FIXME: this must be changed in the future if the timestamp is passed with - # all messages and not only with the messages coming from the history. - self.notify(nick, message) + def _historyGetEb(err): + log.error(_(u"Can't get history")) + self._onHistoryPrinted() + + self.host.bridge.historyGet(unicode(self.host.profiles[profile].whoami.bare), unicode(target), size, True, search, profile, callback=_historyGetCb, errback=_historyGetEb) - def printInfo(self, msg, type_='normal', extra=None): - """Print general info. + def messageNew(self, uid, timestamp, from_jid, to_jid, msg, subject, type_, extra, profile): + log.debug(u"messageNew ==> {}".format((uid, timestamp, from_jid, to_jid, msg, subject, type_, extra, profile))) + if self._locked: + self._cache.append(uid, timestamp, from_jid, to_jid, msg, subject, type_, extra, profile) + return + if self.type == C.CHAT_GROUP: + if to_jid.resource and type_ != C.MESS_TYPE_GROUPCHAT: + # we have a private message, we forward it to a private conversation widget + chat_widget = self.getOrCreatePrivateWidget(to_jid) + chat_widget.messageNew(uid, timestamp, from_jid, to_jid, msg, subject, type_, extra, profile) + return + if type_ == C.MESS_TYPE_INFO: + try: + info_type = extra['info_type'] + except KeyError: + pass + else: + user_data = {k[5:]:v for k,v in extra.iteritems() if k.startswith('user_')} + if info_type == ROOM_USER_JOINED: + self.addUser(user_data) + elif info_type == ROOM_USER_LEFT: + self.removeUser(user_data) - @param msg (unicode): message to print - @param type_ (unicode): - - 'normal': general info like "toto has joined the room" - - 'me': "/me" information like "/me clenches his fist" ==> "toto clenches his fist" - @param extra (dict): message data + message = Message(self, uid, timestamp, from_jid, to_jid, msg, subject, type_, extra, profile) + self.messages[uid] = message + + if 'received_timestamp' in extra: + log.warning(u"Delayed message received after history, this should not happen") + self.createMessage(message) + + def createMessage(self, message, append=False): + """Must be implemented by frontend to create and show a new message widget + + This is only called on messageNew, not on history. + You need to override historyPrint to handle the later + @param message(Message): message data """ - self.notify(msg=msg) + raise NotImplementedError def notify(self, contact="somebody", msg=""): """Notify the user of a new message if the frontend doesn't have the focus. @@ -255,6 +346,7 @@ @param contact (unicode): contact who wrote to the users @param msg (unicode): the message that has been received """ + # FIXME: not called anymore after refactoring raise NotImplemented def printDayChange(self, day): @@ -262,21 +354,16 @@ @param day(unicode): day to display (or not if this method is not overwritten) """ + # FIXME: not called anymore after refactoring pass - def getEntityStates(self, entity): - """Retrieve states for an entity. + ## Room ## - @param entity (jid.JID): entity - @return: OrderedDict{unicode: unicode} - """ - states = OrderedDict() - clist = self.host.contact_lists[self.profile] - for key in self.visible_states: - value = clist.getCache(entity, key) - if value: - states[key] = value - return states + def setSubject(self, subject): + """Set title for a group chat""" + self.subject = subject + if self.type != C.CHAT_GROUP: + raise exceptions.InternalError("trying to set subject for a non group chat window") def addGamePanel(self, widget): """Insert a game panel to this Chat dialog.
--- a/frontends/src/quick_frontend/quick_contact_list.py Fri Jun 24 22:32:58 2016 +0200 +++ b/frontends/src/quick_frontend/quick_contact_list.py Fri Jun 24 22:41:28 2016 +0200 @@ -17,12 +17,16 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. +"""Contact List handling multi profiles at once, should replace quick_contact_list module in the future""" + from sat.core.i18n import _ from sat.core.log import getLogger log = getLogger(__name__) +from sat.core import exceptions from sat_frontends.quick_frontend.quick_widgets import QuickWidget from sat_frontends.quick_frontend.constants import Const as C from sat_frontends.tools import jid +from collections import OrderedDict try: @@ -37,14 +41,19 @@ iter_cpy.sort(key=key) return pyjamas_max(iter_cpy) +handler = None -class QuickContactList(QuickWidget): - """This class manage the visual representation of contacts""" + +class ProfileContactList(object): + """Contact list data for a single profile""" - def __init__(self, host, profile): - log.debug(_("Contact List init")) - super(QuickContactList, self).__init__(host, profile, profile) + def __init__(self, profile): + self.host = handler.host + self.profile = profile + # contain all jids in roster or not, # bare jids as keys, resources are used in data + # XXX: we don't mutualise cache, as values may differ + # for different profiles (e.g. directed presence) self._cache = {} # special entities (groupchat, gateways, etc), bare jids @@ -53,38 +62,50 @@ self._special_extras = set() # group data contain jids in groups and misc frontend data + # None key is used for jids with not group self._groups = {} # groups to group data map # contacts in roster (bare jids) self._roster = set() - # entities with alert(s) and their counts (usually a waiting message), dict{full jid: int) - self._alerts = dict() + # alerts per entity (key: full jid, value: list of alerts) + self._alerts = {} # selected entities, full jid self._selected = set() # we keep our own jid - self.whoami = host.profiles[profile].whoami + self.whoami = self.host.profiles[profile].whoami # options self.show_disconnected = False self.show_empty_groups = True self.show_resources = False self.show_status = False - # TODO: this may lead to two successive UI refresh and needs an optimization + self.host.bridge.asyncGetParamA(C.SHOW_EMPTY_GROUPS, "General", profile_key=profile, callback=self._showEmptyGroups) self.host.bridge.asyncGetParamA(C.SHOW_OFFLINE_CONTACTS, "General", profile_key=profile, callback=self._showOfflineContacts) # FIXME: workaround for a pyjamas issue: calling hash on a class method always return a different value if that method is defined directly within the class (with the "def" keyword) self.presenceListener = self.onPresenceUpdate - self.host.addListener('presence', self.presenceListener, [profile]) + self.host.addListener('presence', self.presenceListener, [self.profile]) self.nickListener = self.onNickUpdate - self.host.addListener('nick', self.nickListener, [profile]) + self.host.addListener('nick', self.nickListener, [self.profile]) + + def _showEmptyGroups(self, show_str): + # Called only by __init__ + # self.update is not wanted here, as it is done by + # handler when all profiles are ready + self.showEmptyGroups(C.bool(show_str)) + + def _showOfflineContacts(self, show_str): + # same comments as for _showEmptyGroups + self.showOfflineContacts(C.bool(show_str)) def __contains__(self, entity): """Check if entity is in contact list + An entity can be in contact list even if not in roster @param entity (jid.JID): jid of the entity (resource is not ignored, use bare jid if needed) """ if entity.resource: @@ -95,61 +116,97 @@ return entity in self._cache @property - def roster_entities(self): + def roster(self): """Return all the bare JIDs of the roster entities. - @return: set(jid.JID) + @return (set[jid.JID]) """ return self._roster @property - def roster_entities_connected(self): + def roster_connected(self): """Return all the bare JIDs of the roster entities that are connected. - @return: set(jid.JID) + @return (set[jid.JID]) """ return set([entity for entity in self._roster if self.getCache(entity, C.PRESENCE_SHOW) is not None]) @property def roster_entities_by_group(self): - """Return a dictionary binding the roster groups to their entities bare - JIDs. This also includes the empty group (None key). + """Return a dictionary binding the roster groups to their entities bare JIDs. - @return: dict{unicode: set(jid.JID)} + This also includes the empty group (None key). + @return (dict[unicode,set(jid.JID)]) """ return {group: self._groups[group]['jids'] for group in self._groups} @property - def roster_groups_by_entity(self): - """Return a dictionary binding the entities bare JIDs to their roster - groups. The empty group is filtered out. + def roster_groups_by_entities(self): + """Return a dictionary binding the entities bare JIDs to their roster groups - @return: dict{jid.JID: set(unicode)} + @return (dict[jid.JID, set(unicode)]) """ result = {} for group, data in self._groups.iteritems(): - if group is None: - continue for entity in data['jids']: result.setdefault(entity, set()).add(group) return result + @property + def selected(self): + """Return contacts currently selected + + @return (set): set of selected entities + """ + return self._selected + + @property + def items(self): + """Return item representation for all visible entities in cache + + entities are not sorted + key: bare jid, value: data + """ + return {jid_:cache for jid_, cache in self._cache.iteritems() if self.entityToShow(jid_)} + + def getItem(self, entity): + """Return item representation of requested entity + + @param entity(jid.JID): bare jid of entity + @raise (KeyError): entity is unknown + """ + return self._cache[entity] + + def getSpecialExtras(self, special_type=None): + """Return special extras with given type + + If special_type is None, return all special extras. + + @param special_type(unicode, None): one of special type (e.g. C.CONTACT_SPECIAL_GROUP) + None to return all special extras. + @return (set[jid.JID]) + """ + if special_type is None: + return self._special_extras + specials = self.getSpecials(special_type) + return {extra for extra in self._special_extras if extra.bare in specials} + def _gotContacts(self, contacts): + """Called during filling, add contacts and notice parent that contacts are filled""" for contact in contacts: self.host.newContactHandler(*contact, profile=self.profile) + handler._contactsFilled(self.profile) - def fill(self): - """Get all contacts from backend, and fill the widget + def _fill(self): + """Get all contacts from backend Contacts will be cleared before refilling them """ self.clearContacts(keep_cache=True) - self.host.bridge.getContacts(self.profile, callback=self._gotContacts) - def update(self): - """Update the display when something changed""" - raise NotImplementedError + def fill(self): + handler.fill(self.profile) def getCache(self, entity, name=None): """Return a cache value for a contact @@ -192,7 +249,7 @@ @param entity(JID): entity to update @param name(unicode): value to set or update """ - self.setContact(entity, None, {name: value}) + self.setContact(entity, attributes={name: value}) def getFullJid(self, entity): """Get full jid from a bare jid @@ -202,7 +259,7 @@ @raise ValueError: the entity is not bare """ if entity.resource: - raise ValueError("getFullJid must be used with a bare jid") + raise ValueError(u"getFullJid must be used with a bare jid") main_resource = self.getCache(entity, C.CONTACT_MAIN_RESOURCE) return jid.JID(u"{}/{}".format(entity, main_resource)) @@ -213,7 +270,6 @@ @param name: name of the data (can't be "jids") @param value: value to set """ - # FIXME: this is never used, should it be removed? assert name is not 'jids' self._groups[group][name] = value @@ -238,9 +294,9 @@ self.setCache(entity, C.CONTACT_SPECIAL, special_type) def getSpecials(self, special_type=None): - """Return all the bare JIDs of the special roster entities of the type - specified by special_type. If special_type is None, return all specials. + """Return all the bare JIDs of the special roster entities of with given type. + If special_type is None, return all specials. @param special_type: one of special type (e.g. C.CONTACT_SPECIAL_GROUP) or None to return all specials. @return: set(jid.JID) """ @@ -248,24 +304,17 @@ return self._specials return set([entity for entity in self._specials if self.getCache(entity, C.CONTACT_SPECIAL) == special_type]) - def getSpecialExtras(self, special_type=None): - """Return all the JIDs of the special extras entities that are related - to a special entity of the type specified by special_type. - If special_type is None, return all special extras. - @param special_type: one of special type (e.g. C.CONTACT_SPECIAL_GROUP) or None to return all special extras. - @return: set(jid.JID) - """ - if special_type is None: - return self._special_extras - return set([extra for extra in self._special_extras if extra.bare in self.getSpecials(special_type)]) + def disconnect(self): + # for now we just clear contacts on disconnect + self.clearContacts() def clearContacts(self, keep_cache=False): """Clear all the contact list @param keep_cache: if True, don't reset the cache """ - self.unselectAll() + self.select(None) if not keep_cache: self._cache.clear() self._groups.clear() @@ -295,11 +344,13 @@ attributes = {} entity_bare = entity.bare + update_type = C.UPDATE_MODIFY if entity_bare in self._cache else C.UPDATE_ADD if in_roster: self._roster.add(entity_bare) - cache = self._cache.setdefault(entity_bare, {C.CONTACT_RESOURCES: {}}) + cache = self._cache.setdefault(entity_bare, {C.CONTACT_RESOURCES: {}, + C.CONTACT_SELECTED: set()}) assert not C.CONTACT_DATA_FORBIDDEN.intersection(attributes) # we don't want forbidden data in attributes @@ -324,18 +375,12 @@ self._specials.add(entity_bare) cache[C.CONTACT_MAIN_RESOURCE] = None - # now the attribute we keep in cache + # now the attributes we keep in cache for attribute, value in attributes.iteritems(): cache[attribute] = value # we can update the display - self.update() - - def getContacts(self): - """Return contacts currently selected - - @return (set): set of selected entities""" - return self._selected + self.update([entity], update_type, self.profile) def entityToShow(self, entity, check_resource=False): """Tell if the contact should be showed or hidden. @@ -362,8 +407,9 @@ @param entities (list[jid.JID]): list of jids @param check_resources (bool): True if resources must be significant - @return: bool + @return (bool): True if a least one entity need to be shown """ + # FIXME: looks inefficient, really needed? for entity in entities: if self.entityToShow(entity, check_resources): return True @@ -378,24 +424,25 @@ """ return entity in self.getGroupData(group, "jids") - def removeContact(self, entity, in_roster=False): + def removeContact(self, entity): """remove a contact from the list @param entity(jid.JID): jid of the entity to remove (bare jid is used) - @param in_roster (bool): True if contact is from roster """ entity_bare = entity.bare try: groups = self._cache[entity_bare].get(C.CONTACT_GROUPS, set()) except KeyError: - log.warning(_(u"Trying to delete an unknow entity [{}]").format(entity)) - if in_roster: + log.error(_(u"Trying to delete an unknow entity [{}]").format(entity)) + try: self._roster.remove(entity_bare) + except KeyError: + pass del self._cache[entity_bare] for group in groups: self._groups[group]['jids'].remove(entity_bare) if not self._groups[group]['jids']: - self._groups.pop(group) + self._groups.pop(group) # FIXME: we use pop because of pyjamas: http://wiki.goffi.org/wiki/Issues_with_Pyjamas/en for iterable in (self._selected, self._alerts, self._specials, self._special_extras): to_remove = set() for set_entity in iterable: @@ -406,7 +453,7 @@ else: # XXX: self._alerts is a dict for item in to_remove: del iterable[item] - self.update() + self.update([entity], C.UPDATE_DELETE, self.profile) def onPresenceUpdate(self, entity, show, priority, statuses, profile): """Update entity's presence status @@ -440,7 +487,7 @@ if entity.bare not in self._specials: priority_resource = max(resources_data, key=lambda res: resources_data[res][C.PRESENCE_PRIORITY]) cache[C.CONTACT_MAIN_RESOURCE] = priority_resource - self.update() + self.update([entity], C.UPDATE_MODIFY, self.profile) def onNickUpdate(self, entity, new_nick, profile): """Update entity's nick @@ -449,47 +496,83 @@ @param new_nick(unicode): new nick of the entity @param profile: %(doc_profile)s """ - raise NotImplementedError # Must be implemented by frontends + assert profile == self.profile + self.setCache(entity, 'nick', new_nick) + self.update([entity], C.UPDATE_MODIFY, profile) + + def unselect(self, entity): + """Unselect an entity - def unselectAll(self): - """Unselect all contacts""" - self._selected.clear() - self.update() + @param entity(jid.JID): entity to unselect + """ + try: + cache = self._cache[entity.bare] + except: + log.error(u"Try to unselect an entity not in cache") + else: + try: + cache[C.CONTACT_SELECTED].remove(entity.resource) + except KeyError: + log.error(u"Try to unselect a not selected entity") + else: + self._selected.remove(entity) + self.update([entity], C.UPDATE_SELECTION) def select(self, entity): """Select an entity - @param entity(jid.JID): entity to select (resource is significant) + @param entity(jid.JID, None): entity to select (resource is significant) + None to unselect all entities """ - log.debug(u"select %s" % entity) - self._selected.add(entity) - self.update() + if entity is None: + self._selected.clear() + for cache in self._cache.itervalues(): + cache[C.CONTACT_SELECTED].clear() + self.update(type_=C.UPDATE_SELECTION, profile=self.profile) + else: + log.debug(u"select %s" % entity) + try: + cache = self._cache[entity.bare] + except: + log.error(u"Try to select an entity not in cache") + else: + cache[C.CONTACT_SELECTED].add(entity.resource) + self._selected.add(entity) + self.update([entity], C.UPDATE_SELECTION, profile=self.profile) - def getAlerts(self, entity, use_bare_jid=False): - """Return the number of alerts set to this entity. - + def getAlerts(self, entity, use_bare_jid=False, filter_=None): + """Return alerts set to this entity. + @param entity (jid.JID): entity @param use_bare_jid (bool): if True, cumulate the alerts of all the resources sharing the same bare JID - @return int + @param filter_(iterable, None): alert to take into account, + None to count all of them + @return (list[unicode,None]): list of C.ALERT_* or None for undefined ones """ + return [] # FIXME: temporarily disabled if not use_bare_jid: - return self._alerts.get(entity, 0) - - alerts = {} - for contact in self._alerts: - alerts.setdefault(contact.bare, 0) - alerts[contact.bare] += self._alerts[contact] - return alerts.get(entity.bare, 0) + alerts = self._alerts.get(entity, []) + else: + alerts = [] + for contact, contact_alerts in self._alerts: + if contact.bare == entity: + alerts.extend(contact_alerts) + if filter_ is None: + return alerts + else: + return [alert for alert in alerts if alert in filter_] - def addAlert(self, entity): - """Increase the alerts counter for this entity (usually for a waiting message) + def addAlert(self, entity, type_=None): + """Add an alert for this enity - @param entity(jid.JID): entity which must displayed in alert mode (resource is significant) + @param entity(jid.JID): entity who received an alert (resource is significant) + @param type_(unicode, None): type of alert (C.ALERT_*) + None for generic alert """ - self._alerts.setdefault(entity, 0) - self._alerts[entity] += 1 - self.update() - self.host.updateAlertsCounter() + self._alerts.setdefault(entity, []) + self._alerts[entity].append(type_) + self.update([entity], C.UPDATE_MODIFY, self.profile) + self.host.updateAlertsCounter() # FIXME: ? def removeAlerts(self, entity, use_bare_jid=True): """Eventually remove an alert on the entity (usually for a waiting message). @@ -506,16 +589,16 @@ return # nothing changed for entity in to_remove: del self._alerts[entity] + self.update([to_remove], C.UPDATE_MODIFY, self.profile) + self.host.updateAlertsCounter() # FIXME: ? else: try: del self._alerts[entity] except KeyError: return # nothing changed - self.update() - self.host.updateAlertsCounter() - - def _showOfflineContacts(self, show_str): - self.showOfflineContacts(C.bool(show_str)) + else: + self.update([entity], C.UPDATE_MODIFY, self.profile) + self.host.updateAlertsCounter() # FIXME: ? def showOfflineContacts(self, show): """Tell if offline contacts should shown @@ -526,25 +609,319 @@ if self.show_disconnected == show: return self.show_disconnected = show - self.update() - - def _showEmptyGroups(self, show_str): - self.showEmptyGroups(C.bool(show_str)) + self.update(type_=C.UPDATE_STRUCTURE, profile=self.profile) def showEmptyGroups(self, show): assert isinstance(show, bool) if self.show_empty_groups == show: return self.show_empty_groups = show - self.update() + self.update(type_=C.UPDATE_STRUCTURE, profile=self.profile) def showResources(self, show): assert isinstance(show, bool) if self.show_resources == show: return self.show_resources = show + self.update(profile=self.profile) + + def plug(self): + handler.addProfile(self.profile) + + def unplug(self): + handler.removeProfile(self.profile) + + def update(self, entities=None, type_=None, profile=None): + handler.update(entities, type_, profile) + + +class QuickContactListHandler(object): + + def __init__(self, host): + super(QuickContactListHandler, self).__init__() + self.host = host + global handler + if handler is not None: + raise exceptions.InternalError(u"QuickContactListHandler must be instanciated only once") + handler = self + self._clist = {} # key: profile, value: ProfileContactList + self._widgets = set() + self._update_locked = False # se to True to ignore updates + + def __getitem__(self, profile): + """Return ProfileContactList instance for the requested profile""" + return self._clist[profile] + + def __contains__(self, entity): + """Check if entity is in contact list + + @param entity (jid.JID): jid of the entity (resource is not ignored, use bare jid if needed) + """ + for contact_list in self._clist.itervalues(): + if entity in contact_list: + return True + return False + + @property + def roster_entities(self): + """Return all the bare JIDs of the roster entities. + + @return (set[jid.JID]) + """ + entities = set() + for contact_list in self._clist.itervalues(): + entities.update(contact_list.roster_entities) + return entities + + @property + def roster_entities_connected(self): + """Return all the bare JIDs of the roster entities that are connected. + + @return (set[jid.JID]) + """ + entities = set() + for contact_list in self._clist.itervalues(): + entities.update(contact_list.roster_entities_connected) + return entities + + @property + def roster_entities_by_group(self): + """Return a dictionary binding the roster groups to their entities bare + JIDs. This also includes the empty group (None key). + + @return (dict[unicode,set(jid.JID)]) + """ + groups = {} + for contact_list in self._clist.itervalues(): + groups.update(contact_list.roster_entities_by_group) + return groups + + @property + def roster_groups_by_entities(self): + """Return a dictionary binding the entities bare JIDs to their roster + groups. + + @return (dict[jid.JID, set(unicode)]) + """ + entities = {} + for contact_list in self._clist.itervalues(): + entities.update(contact_list.roster_groups_by_entities) + return entities + + @property + def selected(self): + """Return contacts currently selected + + @return (set): set of selected entities + """ + entities = set() + for contact_list in self._clist.itervalues(): + entities.update(contact_list.selected) + return entities + + @property + def items(self): + """Return item representation for visible entities in cache + + items are unordered + key: bare jid, value: data + """ + items = {} + for profile, contact_list in self._clist.iteritems(): + for bare_jid, cache in contact_list.items.iteritems(): + data = cache.copy() + items[bare_jid] = data + data[C.CONTACT_PROFILE] = profile + items.update(contact_list.items) + return items + + @property + def items_sorted(self): + """Return item representation for visible entities in cache + + items are ordered using self.items_sort + key: bare jid, value: data + """ + return self.items_sort(self.items) + + def items_sort(self, items): + """sort items + + @param items(dict): items to sort (will be emptied !) + @return (OrderedDict): sorted items + """ + ordered_items = OrderedDict() + bare_jids = sorted(items.keys()) + for jid_ in bare_jids: + ordered_items[jid_] = items.pop(jid_) + return ordered_items + + def register(self, widget): + """Register a QuickContactList widget + + This method should only be used in QuickContactList + """ + self._widgets.add(widget) + + def unregister(self, widget): + """Unregister a QuickContactList widget + + This method should only be used in QuickContactList + """ + self._widgets.remove(widget) + + def addProfiles(self, profiles): + """Add a contact list for plugged profiles + + @param profile(iterable[unicode]): plugged profiles + """ + for profile in profiles: + if profile not in self._clist: + self._clist[profile] = ProfileContactList(profile) + return [self._clist[profile] for profile in profiles] + + def addProfile(self, profile): + return self.addProfiles([profile])[0] + + def removeProfiles(self, profiles): + """Remove given unplugged profiles from contact list + + @param profile(iterable[unicode]): unplugged profiles + """ + for profile in profiles: + del self._clist[profile] + + def removeProfile(self, profile): + self.removeProfiles([profile]) + + def getSpecialExtras(self, special_type=None): + """Return special extras with given type + + If special_type is None, return all special extras. + + @param special_type(unicode, None): one of special type (e.g. C.CONTACT_SPECIAL_GROUP) + None to return all special extras. + @return (set[jid.JID]) + """ + entities = set() + for contact_list in self._clist.itervalues(): + entities.update(contact_list.getSpecialExtras(special_type)) + return entities + + def _contactsFilled(self, profile): + self._to_fill.remove(profile) + if not self._to_fill: + del self._to_fill + self.update() + + def fill(self, profile=None): + """Get all contacts from backend, and fill the widget + + Contacts will be cleared before refilling them + @param profile(unicode, None): profile to fill + None to fill all profiles + """ + try: + to_fill = self._to_fill + except AttributeError: + to_fill = self._to_fill = set() + + # if check if profiles have already been filled + # to void filling them several times + filled = to_fill.copy() + + if profile is not None: + assert profile in self._clist + to_fill.add(profile) + else: + to_fill.update(self._clist.items()) + + remaining = to_fill.difference(filled) + if remaining != to_fill: + log.debug(u"Not re-filling already filled contact list(s) for {}".format(u', '.join(to_fill.intersection(filled)))) + for profile in remaining: + self._clist[profile]._fill() + + def clearContacts(self, keep_cache=False): + """Clear all the contact list + + @param keep_cache: if True, don't reset the cache + """ + for contact_list in self._clist.itervalues(): + contact_list.clearContacts(keep_cache) self.update() + def select(self, entity): + for contact_list in self._clist.itervalues(): + contact_list.select(entity) + + def unselect(self, entity): + for contact_list in self._clist.itervalues(): + contact_list.select(entity) + + def lockUpdate(self, locked=True, do_update=True): + """Forbid contact list updates + + Used mainly while profiles are plugged, as many updates can occurs, causing + an impact on performances + @param locked(bool): updates are forbidden if True + @param do_update(bool): if True, a full update is done after unlocking + if set to False, widget state can be inconsistent, be sure to know + what youa re doing! + """ + log.debug(u"Contact lists updates are now {}".format(u"LOCKED" if locked else u"UNLOCKED")) + self._update_locked = locked + if not locked and do_update: + self.update() + + def update(self, entities=None, type_=None, profile=None): + if not self._update_locked: + for widget in self._widgets: + widget.update(entities, type_, profile) + + +class QuickContactList(QuickWidget): + """This class manage the visual representation of contacts""" + SINGLE=False + PROFILES_MULTIPLE=True + PROFILES_ALLOW_NONE=True # Can be linked to no profile (e.g. at the early forntend start) + + def __init__(self, host, profiles): + super(QuickContactList, self).__init__(host, None, profiles) + handler.register(self) + + # options + # for next values, None means use indivual value per profile + # True or False mean override these values for all profiles + self.show_disconnected = None # TODO + self.show_empty_groups = None # TODO + self.show_resources = None # TODO + self.show_status = None # TODO + + @property + def items(self): + return handler.items + + @property + def items_sorted(self): + return handler.items + + def update(self, entities=None, type_=None, profile=None): + """Update the display when something changed + + @param entities(iterable[jid.JID], None): updated entities, + None to update the whole contact list + @param type_(unicode, None): update type, may be: + - C.UPDATE_DELETE: entity deleted + - C.UPDATE_MODIFY: entity updated + - C.UPDATE_ADD: entity added + - C.UPDATE_SELECTION: selection modified + or None for undefined update + @param profile(unicode, None): profile concerned with the update + None if unknown + """ + raise NotImplementedError + def onDelete(self): QuickWidget.onDelete(self) - self.host.removeListener('presence', self.presenceListener) + handler.unregister(self)
--- a/frontends/src/quick_frontend/quick_widgets.py Fri Jun 24 22:32:58 2016 +0200 +++ b/frontends/src/quick_frontend/quick_widgets.py Fri Jun 24 22:41:28 2016 +0200 @@ -93,16 +93,20 @@ else: return widgets_map.itervalues() - def getWidget(self, class_, target, profile): + def getWidget(self, class_, target=None, profiles=None): """Get a widget without creating it if it doesn't exist. @param class_(class): class of the widget to create @param target: target depending of the widget, usually a JID instance - @param profile (unicode): %(doc_profile)s + @param profiles (unicode, iterable[unicode], None): profile(s) to use (may or may not be + used, depending of the widget class) @return: a class_ instance or None if the widget doesn't exist """ + assert (target is not None) or (profiles is not None) + if profiles is not None and isinstance(profiles, unicode): + profiles = [profiles] class_ = self.getRealClass(class_) - hash_ = class_.getWidgetHash(target, profile) + hash_ = class_.getWidgetHash(target, profiles) try: return self._widgets[class_.__name__][hash_] except KeyError: @@ -140,7 +144,7 @@ if 'profiles' in _kwargs and 'profile' in _kwargs: raise ValueError("You can't have 'profile' and 'profiles' keys at the same time") try: - _kwargs['profiles'] = _kwargs.pop('profile') + _kwargs['profiles'] = [_kwargs.pop('profile')] except KeyError: if not 'profiles' in _kwargs: _kwargs['profiles'] = None @@ -243,8 +247,8 @@ class QuickWidget(object): """generic widget base""" SINGLE=True # if True, there can be only one widget per target(s) - PROFILES_MULTIPLE=False - PROFILES_ALLOW_NONE=False + PROFILES_MULTIPLE=False # If True, this widget can handle several profiles at once + PROFILES_ALLOW_NONE=False # If True, this widget can be used without profile def __init__(self, host, target, profiles=None): """ @@ -266,7 +270,7 @@ if not self.PROFILES_ALLOW_NONE: raise ValueError("profiles can't have a value of None") else: - if not self.PROFILES_MULTIPLE: + if not self.PROFILES_MULTIPLE and len(profiles) != 1: raise ValueError("multiple profiles are not allowed") for profile in profiles: self.addProfile(profile)
--- a/src/bridge/DBus.py Fri Jun 24 22:32:58 2016 +0200 +++ b/src/bridge/DBus.py Fri Jun 24 22:41:28 2016 +0200 @@ -166,6 +166,11 @@ pass @dbus.service.signal(const_INT_PREFIX+const_CORE_SUFFIX, + signature='sdssa{ss}a{ss}sa{ss}s') + def messageNew(self, uid, timestamp, from_jid, to_jid, message, subject, mess_type, extra, profile): + pass + + @dbus.service.signal(const_INT_PREFIX+const_CORE_SUFFIX, signature='ssss') def newAlert(self, message, title, alert_type, profile): pass @@ -176,11 +181,6 @@ pass @dbus.service.signal(const_INT_PREFIX+const_CORE_SUFFIX, - signature='ssssa{ss}s') - def newMessage(self, from_jid, message, mess_type, to_jid, extra, profile): - pass - - @dbus.service.signal(const_INT_PREFIX+const_CORE_SUFFIX, signature='ssss') def paramUpdate(self, name, value, category, profile): pass @@ -321,12 +321,6 @@ return self._callback("getFeatures", unicode(profile_key), callback=callback, errback=errback) @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, - in_signature='ssibss', out_signature='a(dssssa{ss})', - async_callbacks=('callback', 'errback')) - def getHistory(self, from_jid, to_jid, limit, between=True, search='', profile="@NONE@", callback=None, errback=None): - return self._callback("getHistory", unicode(from_jid), unicode(to_jid), limit, between, unicode(search), unicode(profile), callback=callback, errback=errback) - - @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, in_signature='ss', out_signature='s', async_callbacks=None) def getMainResource(self, contact_jid, profile_key="@DEFAULT@"): @@ -405,6 +399,12 @@ return self._callback("getWaitingSub", unicode(profile_key)) @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, + in_signature='ssibss', out_signature='a(sdssa{ss}a{ss}sa{ss})', + async_callbacks=('callback', 'errback')) + def historyGet(self, from_jid, to_jid, limit, between=True, search='', profile="@NONE@", callback=None, errback=None): + return self._callback("historyGet", unicode(from_jid), unicode(to_jid), limit, between, unicode(search), unicode(profile), callback=callback, errback=errback) + + @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, in_signature='s', out_signature='b', async_callbacks=None) def isConnected(self, profile_key="@DEFAULT@"): @@ -423,6 +423,12 @@ return self._callback("loadParamsTemplate", unicode(filename)) @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, + in_signature='sa{ss}a{ss}sa{ss}s', out_signature='', + async_callbacks=('callback', 'errback')) + def messageSend(self, to_jid, message, subject={}, mess_type="auto", extra={}, profile_key="@NONE@", callback=None, errback=None): + return self._callback("messageSend", unicode(to_jid), message, subject, unicode(mess_type), extra, unicode(profile_key), callback=callback, errback=errback) + + @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, in_signature='sis', out_signature='', async_callbacks=None) def paramsRegisterApp(self, xml, security_limit=-1, app=''): @@ -471,12 +477,6 @@ return self._callback("saveParamsTemplate", unicode(filename)) @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, - in_signature='ssssa{ss}s', out_signature='', - async_callbacks=('callback', 'errback')) - def sendMessage(self, to_jid, message, subject='', mess_type="auto", extra={}, profile_key="@NONE@", callback=None, errback=None): - return self._callback("sendMessage", unicode(to_jid), unicode(message), unicode(subject), unicode(mess_type), extra, unicode(profile_key), callback=callback, errback=errback) - - @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, in_signature='sssis', out_signature='', async_callbacks=None) def setParam(self, name, value, category, security_limit=-1, profile_key="@DEFAULT@"): @@ -626,15 +626,15 @@ def entityDataUpdated(self, jid, name, value, profile): self.dbus_bridge.entityDataUpdated(jid, name, value, profile) + def messageNew(self, uid, timestamp, from_jid, to_jid, message, subject, mess_type, extra, profile): + self.dbus_bridge.messageNew(uid, timestamp, from_jid, to_jid, message, subject, mess_type, extra, profile) + def newAlert(self, message, title, alert_type, profile): self.dbus_bridge.newAlert(message, title, alert_type, profile) def newContact(self, contact_jid, attributes, groups, profile): self.dbus_bridge.newContact(contact_jid, attributes, groups, profile) - def newMessage(self, from_jid, message, mess_type, to_jid, extra, profile): - self.dbus_bridge.newMessage(from_jid, message, mess_type, to_jid, extra, profile) - def paramUpdate(self, name, value, category, profile): self.dbus_bridge.paramUpdate(name, value, category, profile)
--- a/src/bridge/bridge_constructor/bridge_template.ini Fri Jun 24 22:32:58 2016 +0200 +++ b/src/bridge/bridge_constructor/bridge_template.ini Fri Jun 24 22:41:28 2016 +0200 @@ -34,17 +34,25 @@ doc_param_2=groups: Roster's groups where the contact is doc_param_3=%(doc_profile)s -[newMessage] +[messageNew] type=signal category=core -sig_in=ssssa{ss}s +sig_in=sdssa{ss}a{ss}sa{ss}s doc=A message has been received -doc_param_0=from_jid: JID where the message is comming from -doc_param_1=message: Message itself -doc_param_2=mess_type: Type of the message (cf RFC 6121 §5.2.2) + C.MESS_TYPE_INFO (system info) +doc_param_0=uid: unique ID of the message (id specific to SàT, this it *NOT* an XMPP id) +doc_param_1=timestamp: when the message was sent (or declared sent for delayed messages) +doc_param_2=from_jid: JID where the message is comming from doc_param_3=to_jid: JID where the message must be sent -doc_param_4=extra: extra message information -doc_param_5=%(doc_profile)s +doc_param_4=message: message itself, can be in several languages (key is language code or '' for default) +doc_param_5=subject: subject of the message, can be in several languages (key is language code or '' for default) +doc_param_6=mess_type: Type of the message (cf RFC 6121 §5.2.2) + C.MESS_TYPE_INFO (system info) +doc_param_7=extra: extra message information, can have data added by plugins and/or: + - thread: id of the thread + - thread_parent: id of the parent of the current thread + - received_timestamp: date of receiption for delayed messages + - delay_sender: entity which has originally sent or which has delayed the message + - info_type: subtype for info messages +doc_param_8=%(doc_profile)s [newAlert] deprecated= @@ -434,20 +442,22 @@ doc_return=List of confirmation request data, same as for [askConfirmation] -[sendMessage] +[messageSend] async= type=method category=core -sig_in=ssssa{ss}s +sig_in=sa{ss}a{ss}sa{ss}s sig_out= -param_2_default='' +param_2_default={} param_3_default="auto" param_4_default={} param_5_default="@NONE@" doc=Send a message doc_param_0=to_jid: JID of the recipient -doc_param_1=message: body of the message -doc_param_2=subject: Subject of the message ('' if no subject) +doc_param_1=message: body of the message: + key is the language of the body, use '' when unknown +doc_param_2=subject: Subject of the message + key is the language of the subkect, use '' when unknown doc_param_3=mess_type: Type of the message (cf RFC 6121 §5.2.2) or "auto" for automatic type detection doc_param_4=extra: optional data that can be used by a plugin to build more specific messages doc_param_5=%(doc_profile_key)s @@ -577,12 +587,12 @@ doc_param_1=%(doc_security_limit)s doc_param_2=app: name of the frontend registering the parameters -[getHistory] +[historyGet] async= type=method category=core sig_in=ssibss -sig_out=a(dssssa{ss}) +sig_out=a(sdssa{ss}a{ss}sa{ss}) param_3_default=True param_4_default='' param_5_default="@NONE@" @@ -593,7 +603,7 @@ doc_param_3=between: True if we want history between the two jids (in both direction), False if we only want messages from from_jid to to_jid doc_param_4=search: pattern to filter the history results doc_param_5=%(doc_profile)s -doc_return=Ordered list (by timestamp) of tuples (timestamp, full from_jid, full to_jid, message, type, extra) +doc_return=Ordered list (by timestamp) of data as in [messageNew] (without final profile) [addContact] type=method
--- a/src/core/constants.py Fri Jun 24 22:32:58 2016 +0200 +++ b/src/core/constants.py Fri Jun 24 22:41:28 2016 +0200 @@ -31,7 +31,7 @@ APP_NAME_SHORT = u'SàT' APP_NAME_FILE = u'sat' APP_NAME_FULL = u'%s (%s)' % (APP_NAME_SHORT, APP_NAME) - APP_VERSION = u'0.6.0D' # Please add 'D' at the end for dev versions + APP_VERSION = u'0.7.0D' # Please add 'D' at the end for dev versions APP_URL = u'http://salut-a-toi.org' @@ -82,6 +82,7 @@ ENTITY_ALL_RESOURCES = '@ALL_RESOURCES@' ENTITY_MAIN_RESOURCE = '@MAIN_RESOURCE@' ENTITY_CAP_HASH = 'CAP_HASH' + ENTITY_TYPE = 'TYPE' ## Roster jids selection ## @@ -98,6 +99,11 @@ MESS_TYPE_GROUPCHAT = 'groupchat' MESS_TYPE_HEADLINE = 'headline' MESS_TYPE_NORMAL = 'normal' + MESS_TYPE_AUTO = 'auto' # magic value to let the backend guess the type + MESS_TYPE_STANDARD = (MESS_TYPE_CHAT, MESS_TYPE_ERROR, MESS_TYPE_GROUPCHAT, MESS_TYPE_HEADLINE, MESS_TYPE_NORMAL) + MESS_TYPE_ALL = MESS_TYPE_STANDARD + (MESS_TYPE_INFO, MESS_TYPE_AUTO) + + MESS_EXTRA_INFO = "info_type" ## Presence ## @@ -113,6 +119,7 @@ ## Common namespaces ## + NS_XML = 'http://www.w3.org/XML/1998/namespace' NS_CLIENT = 'jabber:client' NS_FORWARD = 'urn:xmpp:forward:0' NS_DELAY = 'urn:xmpp:delay' @@ -194,6 +201,12 @@ ## Logging ## + LOG_LVL_DEBUG = 'DEBUG' + LOG_LVL_INFO = 'INFO' + LOG_LVL_WARNING = 'WARNING' + LOG_LVL_ERROR = 'ERROR' + LOG_LVL_CRITICAL = 'CRITICAL' + LOG_LEVELS = (LOG_LVL_DEBUG, LOG_LVL_INFO, LOG_LVL_WARNING, LOG_LVL_ERROR, LOG_LVL_CRITICAL) LOG_BACKEND_STANDARD = 'standard' LOG_BACKEND_TWISTED = 'twisted' LOG_BACKEND_BASIC = 'basic' @@ -204,6 +217,13 @@ LOG_OPT_PREFIX = 'log_' # (option_name, default_value) tuples LOG_OPT_COLORS = ('colors', 'true') # true for auto colors, force to have colors even if stdout is not a tty, false for no color + LOG_OPT_TAINTS_DICT = ('levels_taints_dict', { + LOG_LVL_DEBUG: ('cyan',), + LOG_LVL_INFO: (), + LOG_LVL_WARNING: ('yellow',), + LOG_LVL_ERROR: ('red', 'blink', r'/!\ ', 'blink_off'), + LOG_LVL_CRITICAL: ('bold', 'red', 'Guru Meditation ', 'normal_weight') + }) LOG_OPT_LEVEL = ('level', 'info') LOG_OPT_FORMAT = ('fmt', '%(message)s') # similar to logging format. LOG_OPT_LOGGER = ('logger', '') # regex to filter logger name @@ -213,12 +233,6 @@ LOG_OPT_OUTPUT_MEMORY_LIMIT = 50 LOG_OPT_OUTPUT_FILE = 'file' # file is implicit if only output LOG_OPT_OUTPUT = ('output', LOG_OPT_OUTPUT_SEP + LOG_OPT_OUTPUT_DEFAULT) # //default = normal output (stderr or a file with twistd), path/to/file for a file (must be the first if used), //memory for memory (options can be put in parenthesis, e.g.: //memory(500) for a 500 lines memory) - LOG_LVL_DEBUG = 'DEBUG' - LOG_LVL_INFO = 'INFO' - LOG_LVL_WARNING = 'WARNING' - LOG_LVL_ERROR = 'ERROR' - LOG_LVL_CRITICAL = 'CRITICAL' - LOG_LEVELS = (LOG_LVL_DEBUG, LOG_LVL_INFO, LOG_LVL_WARNING, LOG_LVL_ERROR, LOG_LVL_CRITICAL) ## action constants ## @@ -263,7 +277,7 @@ def LOG_OPTIONS(cls): """Return options checked for logs""" # XXX: we use a classmethod so we can use Const inheritance to change default options - return(cls.LOG_OPT_COLORS, cls.LOG_OPT_LEVEL, cls.LOG_OPT_FORMAT, cls.LOG_OPT_LOGGER, cls.LOG_OPT_OUTPUT) + return(cls.LOG_OPT_COLORS, cls.LOG_OPT_TAINTS_DICT, cls.LOG_OPT_LEVEL, cls.LOG_OPT_FORMAT, cls.LOG_OPT_LOGGER, cls.LOG_OPT_OUTPUT) @classmethod def bool(cls, value):
--- a/src/core/log.py Fri Jun 24 22:32:58 2016 +0200 +++ b/src/core/log.py Fri Jun 24 22:41:28 2016 +0200 @@ -19,6 +19,7 @@ """High level logging functions""" # XXX: this module use standard logging module when possible, but as SàT can work in different cases where logging is not the best choice (twisted, pyjamas, etc), it is necessary to have a dedicated module. Additional feature like environment variables and colors are also managed. +# TODO: change formatting from "%s" style to "{}" when moved to Python 3 from sat.core.constants import Const as C from sat.core import exceptions @@ -26,6 +27,8 @@ backend = None _loggers = {} handlers = {} +COLOR_START = '%(color_start)s' +COLOR_END = '%(color_end)s' class Filtered(Exception): @@ -154,8 +157,9 @@ class ConfigureBase(object): LOGGER_CLASS = Logger + _color_location = False # True if color location is specified in fmt (with COLOR_START) - def __init__(self, level=None, fmt=None, output=None, logger=None, colors=False, force_colors=False, backend_data=None): + def __init__(self, level=None, fmt=None, output=None, logger=None, colors=False, levels_taints_dict=None, force_colors=False, backend_data=None): """Configure a backend @param level: one of C.LOG_LEVELS @@ -174,7 +178,7 @@ self.configureFormat(fmt) self.configureOutput(output) self.configureLogger(logger) - self.configureColors(colors, force_colors) + self.configureColors(colors, force_colors, levels_taints_dict) self.postTreatment() self.updateCurrentLogger() @@ -201,6 +205,11 @@ if fmt is not None: if fmt != '%(message)s': # %(message)s is the same as None Logger.fmt = fmt + if COLOR_START in fmt: + ConfigureBase._color_location = True + if fmt.find(COLOR_END,fmt.rfind(COLOR_START))<0: + # color_start not followed by an end, we add it + Logger.fmt += COLOR_END def configureOutput(self, output): if output is not None: @@ -212,8 +221,29 @@ if logger: Logger.filter_name = FilterName(logger) - def configureColors(self, colors, force_colors): - pass + def configureColors(self, colors, force_colors, levels_taints_dict): + if colors: + # if color are used, we need to handle levels_taints_dict + for level in levels_taints_dict.keys(): + # we wants levels in uppercase to correspond to contstants + levels_taints_dict[level.upper()] = levels_taints_dict[level] + taints = self.__class__.taints = {} + for level in C.LOG_LEVELS: + # we want use values and use constant value as default + taint_list = levels_taints_dict.get(level, C.LOG_OPT_TAINTS_DICT[1][level]) + ansi_list = [] + for elt in taint_list: + elt = elt.upper() + try: + ansi = getattr(C, 'ANSI_FG_{}'.format(elt)) + except AttributeError: + try: + ansi = getattr(C, 'ANSI_{}'.format(elt)) + except AttributeError: + # we use raw string if element is unknown + ansi = elt + ansi_list.append(ansi) + taints[level] = ''.join(ansi_list) def postTreatment(self): pass @@ -249,7 +279,7 @@ handlers[output] = None elif output == C.LOG_OPT_OUTPUT_FILE: if not options: - ValueError("%(handler)s output need a path as option" % {'handler': output}) + ValueError("{handler} output need a path as option" .format(handle=output)) handlers.setdefault(output, []).append(options) options = None # option are parsed, we can empty them elif output == C.LOG_OPT_OUTPUT_MEMORY: @@ -262,7 +292,7 @@ handlers[output] = limit if options: # we should not have unparsed options - raise ValueError(u"options [%(options)s] are not supported for %(handler)s output" % {'options': options, 'handler': output}) + raise ValueError(u"options [{options}] are not supported for {handler} output".format(options=options, handler=output)) @staticmethod def memoryGet(size=None): @@ -272,35 +302,25 @@ """ raise NotImplementedError - @staticmethod - def ansiColors(level, message): + @classmethod + def ansiColors(cls, level, message): """Colorise message depending on level for terminals @param level: one of C.LOG_LEVELS @param message: formatted message to log @return: message with ANSI escape codes for coloration """ - if level == C.LOG_LVL_DEBUG: - out = (C.ANSI_FG_CYAN, message, C.ANSI_RESET) - elif level == C.LOG_LVL_WARNING: - out = (C.ANSI_FG_YELLOW, message, C.ANSI_RESET) - elif level == C.LOG_LVL_ERROR: - out = (C.ANSI_FG_RED, - C.ANSI_BLINK, - r'/!\ ', - C.ANSI_BLINK_OFF, - message, - C.ANSI_RESET) - elif level == C.LOG_LVL_CRITICAL: - out = (C.ANSI_BOLD, - C.ANSI_FG_RED, - 'Guru Meditation ', - C.ANSI_NORMAL_WEIGHT, - message, - C.ANSI_RESET) + + try: + start = cls.taints[level] + except KeyError: + start = '' + + if cls._color_location: + return message % {'color_start': start, + 'color_end': C.ANSI_RESET} else: - out = message - return ''.join(out) + return '%s%s%s' % (start, message, C.ANSI_RESET) @staticmethod def getProfile(): @@ -334,7 +354,7 @@ try: configure_class = configure_cls[backend] except KeyError: - raise ValueError("unknown backend [%s]" % backend) + raise ValueError("unknown backend [{}]".format(backend)) if backend == C.LOG_BACKEND_CUSTOM: logger_class = options.pop('logger_class') configure_class(logger_class, **options) @@ -350,7 +370,7 @@ try: logger_class = configure_cls[backend].LOGGER_CLASS except KeyError: - raise ValueError("This method should not be called with backend [%s]" % backend) + raise ValueError("This method should not be called with backend [{}]".format(backend)) return _loggers.setdefault(name, logger_class(name)) _root_logger = getLogger()
--- a/src/core/log_config.py Fri Jun 24 22:32:58 2016 +0200 +++ b/src/core/log_config.py Fri Jun 24 22:41:28 2016 +0200 @@ -43,12 +43,13 @@ class ConfigureBasic(log.ConfigureBase): - def configureColors(self, colors, force_colors): + def configureColors(self, colors, force_colors, levels_taints_dict): + super(ConfigureBasic, self).configureColors(colors, force_colors, levels_taints_dict) if colors: import sys if force_colors or sys.stdout.isatty(): # FIXME: isatty should be tested on each handler, not globaly # we need colors - log.Logger.post_treat = lambda self, level, message: self.ansiColors(level, message) + log.Logger.post_treat = lambda logger, level, message: self.ansiColors(level, message) elif force_colors: raise ValueError("force_colors can't be used if colors is False") @@ -210,7 +211,8 @@ if C.LOG_OPT_OUTPUT_MEMORY in log.handlers: raise NotImplementedError("Memory observer is not implemented in Twisted backend") - def configureColors(self, colors, force_colors): + def configureColors(self, colors, force_colors, levels_taints_dict): + super(ConfigureTwisted, self).configureColors(colors, force_colors, levels_taints_dict) self.LOGGER_CLASS.colors = colors self.LOGGER_CLASS.force_colors = force_colors if force_colors and not colors: @@ -235,12 +237,12 @@ class ConfigureStandard(ConfigureBasic): - def __init__(self, level=None, fmt=None, output=None, logger=None, colors=False, force_colors=False, backend_data=None): + def __init__(self, level=None, fmt=None, output=None, logger=None, colors=False, levels_taints_dict=None, force_colors=False, backend_data=None): if fmt is None: fmt = C.LOG_OPT_FORMAT[1] if output is None: output = C.LOG_OPT_OUTPUT[1] - super(ConfigureStandard, self).__init__(level, fmt, output, logger, colors, force_colors, backend_data) + super(ConfigureStandard, self).__init__(level, fmt, output, logger, colors, levels_taints_dict, force_colors, backend_data) def preTreatment(self): """We use logging methods directly, instead of using Logger""" @@ -258,6 +260,7 @@ self.level = level def configureFormat(self, fmt): + super(ConfigureStandard, self).configureFormat(fmt) import logging class SatFormatter(logging.Formatter): @@ -272,8 +275,17 @@ def format(self, record): if self._with_profile: record.profile = ConfigureStandard.getProfile() + do_color = self.with_colors and (self.can_colors or self.force_colors) + if ConfigureStandard._color_location: + # we copy raw formatting strings for color_* + # as formatting is handled in ansiColors in this case + if do_color: + record.color_start = log.COLOR_START + record.color_end = log.COLOR_END + else: + record.color_start = record.color_end = '' s = super(SatFormatter, self).format(record) - if self.with_colors and (self.can_colors or self.force_colors): + if do_color: s = ConfigureStandard.ansiColors(record.levelname, s) return s @@ -285,7 +297,8 @@ def configureLogger(self, logger): self.name_filter = log.FilterName(logger) if logger else None - def configureColors(self, colors, force_colors): + def configureColors(self, colors, force_colors, levels_taints_dict): + super(ConfigureStandard, self).configureColors(colors, force_colors, levels_taints_dict) self.formatterClass.with_colors = colors self.formatterClass.force_colors = force_colors if not colors and force_colors: @@ -388,19 +401,15 @@ global C C = const log.C = const - import ConfigParser + from sat.tools import config import os log_conf = {} - config = ConfigParser.SafeConfigParser() - config.read(C.CONFIG_FILES) + sat_conf = config.parseMainConf() for opt_name, opt_default in C.LOG_OPTIONS(): try: log_conf[opt_name] = os.environ[''.join((C.ENV_PREFIX, C.LOG_OPT_PREFIX.upper(), opt_name.upper()))] except KeyError: - try: - log_conf[opt_name] = config.get(C.LOG_OPT_SECTION, C.LOG_OPT_PREFIX + opt_name) - except (ConfigParser.NoOptionError, ConfigParser.NoSectionError): - log_conf[opt_name] = opt_default + log_conf[opt_name] = config.getConfig(sat_conf, C.LOG_OPT_SECTION, C.LOG_OPT_PREFIX + opt_name, opt_default) _parseOptions(log_conf) configure(backend, backend_data=backend_data, **log_conf)
--- a/src/core/sat_main.py Fri Jun 24 22:32:58 2016 +0200 +++ b/src/core/sat_main.py Fri Jun 24 22:41:28 2016 +0200 @@ -40,6 +40,7 @@ import sys import os.path import uuid +import time try: from collections import OrderedDict # only available from python 2.7 @@ -85,7 +86,7 @@ self.bridge.register("getPresenceStatuses", self.memory._getPresenceStatuses) self.bridge.register("getWaitingSub", self.memory.getWaitingSub) self.bridge.register("getWaitingConf", self.getWaitingConf) - self.bridge.register("sendMessage", self._sendMessage) + self.bridge.register("messageSend", self._messageSend) self.bridge.register("getConfig", self._getConfig) self.bridge.register("setParam", self.setParam) self.bridge.register("getParamA", self.memory.getStringParamA) @@ -94,7 +95,7 @@ self.bridge.register("getParamsUI", self.memory.getParamsUI) self.bridge.register("getParamsCategories", self.memory.getParamsCategories) self.bridge.register("paramsRegisterApp", self.memory.paramsRegisterApp) - self.bridge.register("getHistory", self.memory.getHistory) + self.bridge.register("historyGet", self.memory._historyGet) self.bridge.register("setPresence", self._setPresence) self.bridge.register("subscription", self.subscription) self.bridge.register("addContact", self._addContact) @@ -527,133 +528,177 @@ ret.append((conf_id, conf_type, data)) return ret - def generateMessageXML(self, mess_data): - mess_data['xml'] = domish.Element((None, 'message')) - mess_data['xml']["to"] = mess_data["to"].full() - mess_data['xml']["from"] = mess_data['from'].full() - mess_data['xml']["type"] = mess_data["type"] - mess_data['xml']['id'] = str(uuid4()) - if mess_data["subject"]: - mess_data['xml'].addElement("subject", None, mess_data['subject']) - if mess_data["message"]: # message without body are used to send chat states - mess_data['xml'].addElement("body", None, mess_data["message"]) - return mess_data + def generateMessageXML(self, data): + """Generate <message/> stanza from message data - def _sendMessage(self, to_s, msg, subject=None, mess_type='auto', extra={}, profile_key=C.PROF_KEY_NONE): - to_jid = jid.JID(to_s) - #XXX: we need to use the dictionary comprehension because D-Bus return its own types, and pickle can't manage them. TODO: Need to find a better way - return self.sendMessage(to_jid, msg, subject, mess_type, {unicode(key): unicode(value) for key, value in extra.items()}, profile_key=profile_key) + @param data(dict): message data + domish element will be put in data['xml'] + following keys are needed: + - from + - to + - uid: can be set to '' if uid attribute is not wanted + - message + - type + - subject + - extra + @return (dict) message data + """ + data['xml'] = message_elt = domish.Element((None, 'message')) + message_elt["to"] = data["to"].full() + message_elt["from"] = data['from'].full() + message_elt["type"] = data["type"] + if data['uid']: # key must be present but can be set to '' + # by a plugin to avoid id on purpose + message_elt['id'] = data['uid'] + for lang, subject in data["subject"].iteritems(): + subject_elt = message_elt.addElement("subject", content=subject) + if lang: + subject_elt[(C.NS_XML, 'lang')] = lang + for lang, message in data["message"].iteritems(): + body_elt = message_elt.addElement("body", content=message) + if lang: + body_elt[(C.NS_XML, 'lang')] = lang + try: + thread = data['extra']['thread'] + except KeyError: + if 'thread_parent' in data['extra']: + raise exceptions.InternalError(u"thread_parent found while there is not associated thread") + else: + thread_elt = message_elt.addElement("thread", content=thread) + try: + thread_elt["parent"] = data["extra"]["thread_parent"] + except KeyError: + pass + return data - def sendMessage(self, to_jid, msg, subject=None, mess_type='auto', extra={}, no_trigger=False, profile_key=C.PROF_KEY_NONE): - #FIXME: check validity of recipient - profile = self.memory.getProfileName(profile_key) - assert profile - client = self.profiles[profile] + def _messageSend(self, to_jid_s, message, subject=None, mess_type='auto', extra=None, profile_key=C.PROF_KEY_NONE): + client = self.getClient(profile_key) + to_jid = jid.JID(to_jid_s) + #XXX: we need to use the dictionary comprehension because D-Bus return its own types, and pickle can't manage them. TODO: Need to find a better way + return self.messageSend(client, to_jid, message, subject, mess_type, {unicode(key): unicode(value) for key, value in extra.items()}) + + def messageSend(self, client, to_jid, message, subject=None, mess_type='auto', extra=None, uid=None, no_trigger=False): + """Send a message to an entity + + @param to_jid(jid.JID): destinee of the message + @param message(dict): message body, key is the language (use '' when unknown) + @param subject(dict): message subject, key is the language (use '' when unknown) + @param mess_type(str): one of standard message type (cf RFC 6121 §5.2.2) or: + - auto: for automatic type detection + - info: for information ("info_type" can be specified in extra) + @param extra(dict, None): extra data. Key can be: + - info_type: information type, can be + TODO + @param uid(unicode, None): unique id: + should be unique at least in this XMPP session + if None, an uuid will be generated + @param no_trigger (bool): if True, messageSend trigger will no be used + useful when a message need to be sent without any modification + """ + profile = client.profile + if subject is None: + subject = {} if extra is None: extra = {} - mess_data = { # we put data in a dict, so trigger methods can change them + data = { # dict is similar to the one used in client.onMessage + "from": client.jid, "to": to_jid, - "from": client.jid, - "message": msg, + "uid": uid or unicode(uuid.uuid4()), + "message": message, "subject": subject, "type": mess_type, "extra": extra, + "timestamp": time.time(), } pre_xml_treatments = defer.Deferred() # XXX: plugin can add their pre XML treatments to this deferred post_xml_treatments = defer.Deferred() # XXX: plugin can add their post XML treatments to this deferred - if mess_data["type"] == "auto": + if data["type"] == "auto": # we try to guess the type - if mess_data["subject"]: - mess_data["type"] = 'normal' - elif not mess_data["to"].resource: # if to JID has a resource, the type is not 'groupchat' + if data["subject"]: + data["type"] = 'normal' + elif not data["to"].resource: # if to JID has a resource, the type is not 'groupchat' # we may have a groupchat message, we check if the we know this jid try: - entity_type = self.memory.getEntityData(mess_data["to"], ['type'], profile)["type"] + entity_type = self.memory.getEntityData(data["to"], ['type'], profile)["type"] #FIXME: should entity_type manage resources ? except (exceptions.UnknownEntityError, KeyError): entity_type = "contact" if entity_type == "chatroom": - mess_data["type"] = 'groupchat' + data["type"] = 'groupchat' else: - mess_data["type"] = 'chat' + data["type"] = 'chat' else: - mess_data["type"] == 'chat' - mess_data["type"] == "chat" if mess_data["subject"] else "normal" + data["type"] == 'chat' + data["type"] == "chat" if data["subject"] else "normal" - send_only = mess_data['extra'].get('send_only', None) + # FIXME: send_only is used by libervia's OTR plugin to avoid + # the triggers from frontend, and no_trigger do the same + # thing internally, this could be unified + send_only = data['extra'].get('send_only', None) if not no_trigger and not send_only: - if not self.trigger.point("sendMessage", mess_data, pre_xml_treatments, post_xml_treatments, profile): + if not self.trigger.point("messageSend", client, data, pre_xml_treatments, post_xml_treatments): return defer.succeed(None) - log.debug(_(u"Sending message (type {type}, to {to})").format(type=mess_data["type"], to=to_jid.full())) + log.debug(_(u"Sending message (type {type}, to {to})").format(type=data["type"], to=to_jid.full())) - def cancelErrorTrap(failure): - """A message sending can be cancelled by a plugin treatment""" - failure.trap(exceptions.CancelError) - - pre_xml_treatments.addCallback(lambda dummy: self.generateMessageXML(mess_data)) + pre_xml_treatments.addCallback(lambda dummy: self.generateMessageXML(data)) pre_xml_treatments.chainDeferred(post_xml_treatments) - post_xml_treatments.addCallback(self._sendMessageToStream, client) + post_xml_treatments.addCallback(self.messageSendToStream, client) if send_only: log.debug(_("Triggers, storage and echo have been inhibited by the 'send_only' parameter")) else: - post_xml_treatments.addCallback(self._storeMessage, client) - post_xml_treatments.addCallback(self.sendMessageToBridge, client) - post_xml_treatments.addErrback(cancelErrorTrap) - pre_xml_treatments.callback(mess_data) + post_xml_treatments.addCallback(self.messageAddToHistory, client) + post_xml_treatments.addCallback(self.messageSendToBridge, client) + post_xml_treatments.addErrback(self._cancelErrorTrap) + pre_xml_treatments.callback(data) return pre_xml_treatments - def _sendMessageToStream(self, mess_data, client): + def _cancelErrorTrap(self, failure): + """A message sending can be cancelled by a plugin treatment""" + failure.trap(exceptions.CancelError) + + def messageSendToStream(self, data, client): """Actualy send the message to the server - @param mess_data: message data dictionnary + @param data: message data dictionnary @param client: profile's client """ - client.xmlstream.send(mess_data['xml']) - return mess_data + client.xmlstream.send(data['xml']) + return data - def _storeMessage(self, mess_data, client): + def messageAddToHistory(self, data, client): """Store message into database (for local history) - @param mess_data: message data dictionnary + @param data: message data dictionnary @param client: profile's client """ - if mess_data["type"] != "groupchat": + if data["type"] != C.MESS_TYPE_GROUPCHAT: # we don't add groupchat message to history, as we get them back # and they will be added then - if mess_data['message']: # we need a message to save something - self.memory.addToHistory(client.jid, mess_data['to'], - unicode(mess_data["message"]), - unicode(mess_data["type"]), - mess_data['extra'], - profile=client.profile) + if data['message'] or data['subject']: # we need a message to store + self.memory.addToHistory(client, data) else: - log.warning(_("No message found")) # empty body should be managed by plugins before this point - return mess_data + log.warning(u"No message found") # empty body should be managed by plugins before this point + return data - def sendMessageToBridge(self, mess_data, client): + def messageSendToBridge(self, data, client): """Send message to bridge, so frontends can display it - @param mess_data: message data dictionnary + @param data: message data dictionnary @param client: profile's client """ - if mess_data["type"] != "groupchat": - # we don't send groupchat message back to bridge, as we get them back + if data["type"] != C.MESS_TYPE_GROUPCHAT: + # we don't send groupchat message to bridge, as we get them back # and they will be added the - if mess_data['message']: # we need a message to save something - # We send back the message, so all clients are aware of it - self.bridge.newMessage(mess_data['from'].full(), - unicode(mess_data["message"]), - mess_type=mess_data["type"], - to_jid=mess_data['to'].full(), - extra=mess_data['extra'], - profile=client.profile) + if data['message'] or data['subject']: # we need a message to send something + # We send back the message, so all frontends are aware of it + self.bridge.messageNew(data['uid'], data['timestamp'], data['from'].full(), data['to'].full(), data['message'], data['subject'], data['type'], data['extra'], profile=client.profile) else: log.warning(_("No message found")) - return mess_data + return data def _setPresence(self, to="", show="", statuses=None, profile_key=C.PROF_KEY_NONE): return self.setPresence(jid.JID(to) if to else None, show, statuses, profile_key) @@ -994,6 +1039,7 @@ def importMenu(self, path, callback, security_limit=C.NO_SECURITY_LIMIT, help_string="", type_=C.MENU_GLOBAL): """register a new menu for frontends + @param path: path to go to the menu (category/subcategory/.../item), must be an iterable (e.g.: ("File", "Open")) /!\ use D_() instead of _() for translations (e.g. (D_("File"), D_("Open"))) @param callback: method to be called when menuitem is selected, callable or a callback id (string) as returned by [registerCallback]
--- a/src/core/xmpp.py Fri Jun 24 22:32:58 2016 +0200 +++ b/src/core/xmpp.py Fri Jun 24 22:41:28 2016 +0200 @@ -20,28 +20,35 @@ from sat.core.i18n import _ from sat.core.constants import Const as C from twisted.internet import task, defer -from twisted.words.protocols.jabber import jid, xmlstream +from twisted.words.protocols.jabber.xmlstream import XMPPHandler +from twisted.words.protocols.jabber import xmlstream from twisted.words.protocols.jabber import error -from wokkel import client, disco, xmppim, generic, delay, iwokkel +from twisted.words.protocols.jabber import jid +from twisted.python import failure +from wokkel import client as wokkel_client, disco, xmppim, generic, iwokkel +from wokkel import delay from sat.core.log import getLogger log = getLogger(__name__) from sat.core import exceptions from zope.interface import implements -from twisted.words.protocols.jabber.xmlstream import XMPPHandler +import time +import calendar +import uuid -class SatXMPPClient(client.XMPPClient): +class SatXMPPClient(wokkel_client.XMPPClient): implements(iwokkel.IDisco) def __init__(self, host_app, profile, user_jid, password, host=None, port=C.XMPP_C2S_PORT, max_retries=C.XMPP_MAX_RETRIES): # XXX: DNS SRV records are checked when the host is not specified. # If no SRV record is found, the host is directly extracted from the JID. - client.XMPPClient.__init__(self, user_jid, password, host or None, port or C.XMPP_C2S_PORT) + wokkel_client.XMPPClient.__init__(self, user_jid, password, host or None, port or C.XMPP_C2S_PORT) self.factory.clientConnectionLost = self.connectionLost self.factory.maxRetries = max_retries self.__connected = False self.profile = profile self.host_app = host_app + self._mess_id_uid = {} # map from message id to uid use in history. Key: (full_jid,message_id) Value: uid self.conn_deferred = defer.Deferred() self._waiting_conf = {} # callback called when a confirmation is received self._progress_cb = {} # callback called when a progress is requested (key = progress id) @@ -73,7 +80,7 @@ def _authd(self, xmlstream): if not self.host_app.trigger.point("XML Initialized", xmlstream, self.profile): return - client.XMPPClient._authd(self, xmlstream) + wokkel_client.XMPPClient._authd(self, xmlstream) self.__connected = True log.info(_("********** [%s] CONNECTED **********") % self.profile) self.streamInitialized() @@ -105,7 +112,7 @@ log.error(_(u"ERROR: XMPP connection failed for profile '%(profile)s': %(reason)s" % {'profile': self.profile, 'reason': reason})) self.conn_deferred.errback(reason.value) try: - client.XMPPClient.initializationFailed(self, reason) + wokkel_client.XMPPClient.initializationFailed(self, reason) except: # we already chained an errback, no need to raise an exception pass @@ -131,58 +138,87 @@ xmppim.MessageProtocol.__init__(self) self.host = host - def onMessage(self, message): - if not message.hasAttribute('from'): - message['from'] = self.parent.jid.host - log.debug(_(u"got message from: %s") % message["from"]) + def onMessage(self, message_elt): + # TODO: handle threads + client = self.parent + if not 'from' in message_elt.attributes: + message_elt['from'] = client.jid.host + log.debug(_(u"got message from: {from_}").format(from_=message_elt['from'])) post_treat = defer.Deferred() # XXX: plugin can add their treatments to this deferred - if not self.host.trigger.point("MessageReceived", message, post_treat, profile=self.parent.profile): + if not self.host.trigger.point("MessageReceived", client, message_elt, post_treat): return - data = {"from": message['from'], - "to": message['to'], - "body": "", - "extra": {}} + message = {} + subject = {} + extra = {} + data = {"from": jid.JID(message_elt['from']), + "to": jid.JID(message_elt['to']), + "uid": message_elt.getAttribute('uid', unicode(uuid.uuid4())), # XXX: uid is not a standard attribute but may be added by plugins + "message": message, + "subject": subject, + "type": message_elt.getAttribute('type', 'normal'), + "extra": extra} - for e in message.elements(): - if e.name == "body": - data['body'] = e.children[0] if e.children else "" - elif e.name == "subject" and e.children: - data['extra']['subject'] = e.children[0] + try: + data['stanza_id'] = message_elt['id'] + except KeyError: + pass + else: + client._mess_id_uid[(data['from'], data['stanza_id'])] = data['uid'] - data['type'] = message['type'] if message.hasAttribute('type') else 'normal' + # message + for e in message_elt.elements(C.NS_CLIENT, 'body'): + message[e.getAttribute((C.NS_XML,'lang'),'')] = unicode(e) - def bridgeSignal(data): - if data is not None: - self.host.bridge.newMessage(data['from'], data['body'], data['type'], data['to'], data['extra'], profile=self.parent.profile) - return data + # subject + for e in message_elt.elements(C.NS_CLIENT, 'subject'): + subject[e.getAttribute((C.NS_XML, 'lang'),'')] = unicode(e) - def addToHistory(data): - try: - timestamp = data['extra']['timestamp'] # timestamp added by XEP-0203 - except KeyError: - self.host.memory.addToHistory(jid.JID(data['from']), jid.JID(data['to']), data['body'], data['type'], data['extra'], profile=self.parent.profile) - else: - if data['type'] != 'groupchat': # XXX: we don't save delayed messages in history for groupchats - #TODO: add delayed messages to history if they aren't already in it - data['extra']['archive'] = timestamp # FIXME: this "archive" is actually never used - self.host.memory.addToHistory(jid.JID(data['from']), jid.JID(data['to']), data['body'], data['type'], data['extra'], timestamp, profile=self.parent.profile) - return data + # delay and timestamp + try: + delay_elt = message_elt.elements(delay.NS_DELAY, 'delay').next() + except StopIteration: + data['timestamp'] = time.time() + else: + parsed_delay = delay.Delay.fromElement(delay_elt) + data['timestamp'] = calendar.timegm(parsed_delay.stamp.utctimetuple()) + data['received_timestamp'] = unicode(time.time()) + if parsed_delay.sender: + data['delay_sender'] = parsed_delay.sender.full() + + + post_treat.addCallback(self.skipEmptyMessage) + post_treat.addCallback(self.addToHistory, client) + post_treat.addErrback(self.treatmentsEb) + post_treat.addCallback(self.bridgeSignal, client, data) + post_treat.addErrback(self.cancelErrorTrap) + post_treat.callback(data) - def treatmentsEb(failure): - failure.trap(exceptions.SkipHistory) - return data + def skipEmptyMessage(self, data): + if not data['message'] and not data['extra'] and not data['subject']: + raise failure.Failure(exceptions.CancelError("Cancelled empty message")) + return data + + def addToHistory(self, data, client): + return self.host.memory.addToHistory(client, data) + + def treatmentsEb(self, failure_): + failure_.trap(exceptions.SkipHistory) - def cancelErrorTrap(failure): - """A message sending can be cancelled by a plugin treatment""" - failure.trap(exceptions.CancelError) + def bridgeSignal(self, dummy, client, data): + try: + data['extra']['received_timestamp'] = data['received_timestamp'] + data['extra']['delay_sender'] = data['delay_sender'] + except KeyError: + pass + if data is not None: + self.host.bridge.messageNew(data['uid'], data['timestamp'], data['from'].full(), data['to'].full(), data['message'], data['subject'], data['type'], data['extra'], profile=client.profile) + return data - post_treat.addCallback(addToHistory) - post_treat.addErrback(treatmentsEb) - post_treat.addCallback(bridgeSignal) - post_treat.addErrback(cancelErrorTrap) - post_treat.callback(data) + def cancelErrorTrap(self, failure_): + """A message sending can be cancelled by a plugin treatment""" + failure_.trap(exceptions.CancelError) class SatRosterProtocol(xmppim.RosterClientProtocol): @@ -244,8 +280,10 @@ def getAttributes(self, item): """Return dictionary of attributes as used in bridge from a RosterItem + @param item: RosterItem - @return: dictionary of attributes""" + @return: dictionary of attributes + """ item_attr = {'to': unicode(item.subscriptionTo), 'from': unicode(item.subscriptionFrom), 'ask': unicode(item.ask) @@ -300,8 +338,9 @@ def getItem(self, entity_jid): """Return RosterItem for a given jid - @param entity_jid: jid of the contact - @return: RosterItem or None if contact is not in cache + @param entity_jid(jid.JID): jid of the contact + @return(RosterItem, None): RosterItem instance + None if contact is not in cache """ return self._jids.get(entity_jid, None) @@ -345,6 +384,18 @@ else: raise ValueError(u'Unexpected type_ {}'.format(type_)) + def getNick(self, entity_jid): + """Return a nick name for an entity + + return nick choosed by user if available + else return user part of entity_jid + """ + item = self.getItem(entity_jid) + if item is None: + return entity_jid.user + else: + return item.name or entity_jid.user + class SatPresenceProtocol(xmppim.PresenceClientProtocol): @@ -435,7 +486,9 @@ return self.send(presence_elt) + @defer.inlineCallbacks def subscribed(self, entity): + yield self.parent.roster.got_roster xmppim.PresenceClientProtocol.subscribed(self, entity) self.host.memory.delWaitingSub(entity.userhost(), self.parent.profile) item = self.parent.roster.getItem(entity) @@ -455,8 +508,10 @@ log.debug(_(u"unsubscription confirmed for [%s]") % entity.userhost()) self.host.bridge.subscribe('unsubscribed', entity.userhost(), self.parent.profile) + @defer.inlineCallbacks def subscribeReceived(self, entity): log.debug(_(u"subscription request from [%s]") % entity.userhost()) + yield self.parent.roster.got_roster item = self.parent.roster.getItem(entity) if item and item.subscriptionTo: # We automatically accept subscription if we are already subscribed to contact presence @@ -466,8 +521,10 @@ self.host.memory.addWaitingSub('subscribe', entity.userhost(), self.parent.profile) self.host.bridge.subscribe('subscribe', entity.userhost(), self.parent.profile) + @defer.inlineCallbacks def unsubscribeReceived(self, entity): log.debug(_(u"unsubscription asked for [%s]") % entity.userhost()) + yield self.parent.roster.got_roster item = self.parent.roster.getItem(entity) if item and item.subscriptionFrom: # we automatically remove contact log.debug(_('automatic contact deletion')) @@ -506,7 +563,7 @@ def connectionMade(self): log.debug(_(u"Connection made with %s" % self.jabber_host)) - self.xmlstream.namespace = "jabber:client" + self.xmlstream.namespace = C.NS_CLIENT self.xmlstream.sendHeader() iq = xmlstream.IQ(self.xmlstream, 'set') @@ -526,10 +583,10 @@ log.debug(_(u"Registration answer: %s") % answer.toXml()) self.xmlstream.sendFooter() - def registrationFailure(self, failure): - log.info(_("Registration failure: %s") % unicode(failure.value)) + def registrationFailure(self, failure_): + log.info(_("Registration failure: %s") % unicode(failure_.value)) self.xmlstream.sendFooter() - raise failure.value + raise failure_.value class SatVersionHandler(generic.VersionHandler):
--- a/src/memory/memory.py Fri Jun 24 22:32:58 2016 +0200 +++ b/src/memory/memory.py Fri Jun 24 22:41:28 2016 +0200 @@ -510,14 +510,15 @@ ## History ## - def addToHistory(self, from_jid, to_jid, message, type_='chat', extra=None, timestamp=None, profile=C.PROF_KEY_NONE): - assert profile != C.PROF_KEY_NONE - if extra is None: - extra = {} - return self.storage.addToHistory(from_jid, to_jid, message, type_, extra, timestamp, profile) + def addToHistory(self, client, data): + return self.storage.addToHistory(data, client.profile) - def getHistory(self, from_jid, to_jid, limit=C.HISTORY_LIMIT_NONE, between=True, search=None, profile=C.PROF_KEY_NONE): + def _historyGet(self, from_jid_s, to_jid_s, limit=C.HISTORY_LIMIT_NONE, between=True, search=None, profile=C.PROF_KEY_NONE): + return self.historyGet(jid.JID(from_jid_s), jid.JID(to_jid_s), limit, between, search, profile) + + def historyGet(self, from_jid, to_jid, limit=C.HISTORY_LIMIT_NONE, between=True, search=None, profile=C.PROF_KEY_NONE): """Retrieve messages in history + @param from_jid (JID): source JID (full, or bare for catchall) @param to_jid (JID): dest JID (full, or bare for catchall) @param limit (int): maximum number of messages to get: @@ -527,7 +528,7 @@ @param between (bool): confound source and dest (ignore the direction) @param search (str): pattern to filter the history results @param profile (str): %(doc_profile)s - @return: list of tuple as in http://wiki.goffi.org/wiki/Bridge_API#getHistory + @return (D(list)): list of message data as in [messageNew] """ assert profile != C.PROF_KEY_NONE if limit == C.HISTORY_LIMIT_DEFAULT: @@ -536,7 +537,7 @@ limit = None if limit == 0: return defer.succeed([]) - return self.storage.getHistory(jid.JID(from_jid), jid.JID(to_jid), limit, between, search, profile) + return self.storage.historyGet(from_jid, to_jid, limit, between, search, profile) ## Statuses ##
--- a/src/memory/sqlite.py Fri Jun 24 22:32:58 2016 +0200 +++ b/src/memory/sqlite.py Fri Jun 24 22:41:28 2016 +0200 @@ -26,14 +26,15 @@ from sat.tools.config import fixConfigOption from twisted.enterprise import adbapi from twisted.internet import defer +from twisted.python import failure from collections import OrderedDict -from time import time import re import os.path import cPickle as pickle import hashlib +import sqlite3 -CURRENT_DB_VERSION = 2 +CURRENT_DB_VERSION = 3 # XXX: DATABASE schemas are used in the following way: # - 'current' key is for the actual database schema, for a new base @@ -49,9 +50,18 @@ ('profiles', (("id INTEGER PRIMARY KEY ASC", "name TEXT"), ("UNIQUE (name)",))), ('message_types', (("type TEXT PRIMARY KEY",), - tuple())), - ('history', (("id INTEGER PRIMARY KEY ASC", "profile_id INTEGER", "source TEXT", "dest TEXT", "source_res TEXT", "dest_res TEXT", "timestamp DATETIME", "message TEXT", "type TEXT", "extra BLOB"), - ("FOREIGN KEY(profile_id) REFERENCES profiles(id) ON DELETE CASCADE", "FOREIGN KEY(type) REFERENCES message_types(type)"))), + tuple())), + ('history', (("uid TEXT PRIMARY KEY", "update_uid TEXT", "profile_id INTEGER", "source TEXT", "dest TEXT", "source_res TEXT", "dest_res TEXT", + "timestamp DATETIME NOT NULL", "received_timestamp DATETIME", # XXX: timestamp is the time when the message was emitted. If received time stamp is not NULL, the message was delayed and timestamp is the declared value (and received_timestamp the time of reception) + "type TEXT", "extra BLOB"), + ("FOREIGN KEY(profile_id) REFERENCES profiles(id) ON DELETE CASCADE", "FOREIGN KEY(type) REFERENCES message_types(type)", + "UNIQUE (profile_id, timestamp, source, dest, source_res, dest_res)" # avoid storing 2 time the same message (specially for delayed cones) + ))), + ('message', (("id INTEGER PRIMARY KEY ASC", "history_uid INTEGER", "message TEXT", "language TEXT"), + ("FOREIGN KEY(history_uid) REFERENCES history(uid) ON DELETE CASCADE",))), + ('subject', (("id INTEGER PRIMARY KEY ASC", "history_uid INTEGER", "subject TEXT", "language TEXT"), + ("FOREIGN KEY(history_uid) REFERENCES history(uid) ON DELETE CASCADE",))), + ('thread', (("id INTEGER PRIMARY KEY ASC", "history_uid INTEGER", "thread_id TEXT", "parent_id TEXT"),("FOREIGN KEY(history_uid) REFERENCES history(uid) ON DELETE CASCADE",))), ('param_gen', (("category TEXT", "name TEXT", "value TEXT"), ("PRIMARY KEY (category,name)",))), ('param_ind', (("category TEXT", "name TEXT", "profile_id INTEGER", "value TEXT"), @@ -66,15 +76,37 @@ ("PRIMARY KEY (namespace, key, profile_id)", "FOREIGN KEY(profile_id) REFERENCES profiles(id) ON DELETE CASCADE"))) )), 'INSERT': OrderedDict(( - ('message_types', (("'chat'",), ("'error'",), ("'groupchat'",), ("'headline'",), ("'normal'",))), + ('message_types', (("'chat'",), + ("'error'",), + ("'groupchat'",), + ("'headline'",), + ("'normal'",), + ("'info'",) # info is not standard, but used to keep track of info like join/leave in a MUC + )), )), }, + 3: {'specific': 'update_v3' + }, 2: {'specific': 'update2raw_v2' }, - 1: {'cols create': {'history': ('extra BLOB',)} + 1: {'cols create': {'history': ('extra BLOB',)}, }, } +NOT_IN_EXTRA = ('received_timestamp', 'update_uid') # keys which are in message data extra but not stored in sqlite's extra field + # this is specific to this sqlite storage and for now only used for received_timestamp + # because this value is stored in a separate field + + +class ConnectionPool(adbapi.ConnectionPool): + # Workaround to avoid IntegrityError causing (i)pdb to be launched in debug mode + def _runQuery(self, trans, *args, **kw): + try: + trans.execute(*args, **kw) + except sqlite3.IntegrityError as e: + raise failure.Failure(e) + return trans.fetchall() + class SqliteStorage(object): """This class manage storage with Sqlite database""" @@ -91,7 +123,7 @@ dir_ = os.path.dirname(db_filename) if not os.path.exists(dir_): os.makedirs(dir_, 0700) - self.dbpool = adbapi.ConnectionPool("sqlite3", db_filename, check_same_thread=False) + self.dbpool = ConnectionPool("sqlite3", db_filename, check_same_thread=False) # init_defer is the initialisation deferred, initialisation is ok when all its callbacks have been done # XXX: foreign_keys activation doesn't seem to work, probably because of the multi-threading @@ -114,7 +146,7 @@ if statements is None: return defer.succeed(None) - log.debug(u"===== COMMITING STATEMENTS =====\n%s\n============\n\n" % '\n'.join(statements)) + log.debug(u"===== COMMITTING STATEMENTS =====\n%s\n============\n\n" % '\n'.join(statements)) d = self.dbpool.runInteraction(self._updateDb, tuple(statements)) return d @@ -169,9 +201,9 @@ """Delete profile @param name: name of the profile @return: deferred triggered once profile is actually deleted""" - def deletionError(failure): + def deletionError(failure_): log.error(_(u"Can't delete profile [%s]") % name) - return failure + return failure_ def delete(txn): profile_id = self.profiles.pop(name) @@ -251,57 +283,138 @@ return d #History - def addToHistory(self, from_jid, to_jid, message, _type='chat', extra=None, timestamp=None, profile=None): + + def _addToHistoryCb(self, dummy, data): + # Message metadata were successfuly added to history + # now we can add message and subject + uid = data['uid'] + for key in ('message', 'subject'): + for lang, value in data[key].iteritems(): + d = self.dbpool.runQuery("INSERT INTO {key}(history_uid, {key}, language) VALUES (?,?,?)".format(key=key), + (uid, value, lang or None)) + d.addErrback(lambda dummy: log.error(_(u"Can't save following {key} in history (uid: {uid}, lang:{lang}): {value}".format( + key=key, uid=uid, lang=lang, value=value)))) + try: + thread = data['extra']['thread'] + except KeyError: + pass + else: + thread_parent = data['extra'].get('thread_parent') + d = self.dbpool.runQuery("INSERT INTO thread(history_uid, thread_id, parent_id) VALUES (?,?,?)", + (uid, thread, thread_parent)) + d.addErrback(lambda dummy: log.error(_(u"Can't save following thread in history (uid: {uid}): thread:{thread}), parent:{parent}".format( + uid=uid, thread=thread, parent=thread_parent)))) + + def _addToHistoryEb(self, failure_, data): + failure_.trap(sqlite3.IntegrityError) + sqlite_msg = failure_.value.args[0] + if "UNIQUE constraint failed" in sqlite_msg: + log.debug(u"message {} is already in history, not storing it again".format(data['uid'])) + if 'received_timestamp' not in data: + log.warning(u"duplicate message is not delayed, this is maybe a bug: data={}".format(data)) + # we cancel message to avoid sending duplicate message to frontends + raise failure.Failure(exceptions.CancelError("Cancelled duplicated message")) + else: + log.error(u"Can't store message in history: {}".format(failure_)) + + def _logHistoryError(self, failure_, from_jid, to_jid, data): + if failure_.check(exceptions.CancelError): + # we propagate CancelError to avoid sending message to frontends + raise failure_ + log.error(_(u"Can't save following message in history: from [{from_jid}] to [{to_jid}] (uid: {uid})" + .format(from_jid=from_jid.full(), to_jid=to_jid.full(), uid=data['uid']))) + + def addToHistory(self, data, profile): """Store a new message in history - @param from_jid: full source JID - @param to_jid: full dest JID - @param message: message - @param _type: message type (see RFC 6121 §5.2.2) - @param extra: dictionary (keys and values are unicode) of extra message data - @param timestamp: timestamp in seconds since epoch, or None to use current time + + @param data(dict): message data as build by SatMessageProtocol.onMessage """ - assert(profile) - if extra is None: - extra = {} - extra_ = pickle.dumps({k: v.encode('utf-8') for k, v in extra.items()}, 0).decode('utf-8') - d = self.dbpool.runQuery("INSERT INTO history(source, source_res, dest, dest_res, timestamp, message, type, extra, profile_id) VALUES (?,?,?,?,?,?,?,?,?)", - (from_jid.userhost(), from_jid.resource, to_jid.userhost(), to_jid.resource, timestamp or time(), - message, _type, extra_, self.profiles[profile])) - d.addErrback(lambda ignore: log.error(_(u"Can't save following message in history: from [%(from_jid)s] to [%(to_jid)s] ==> [%(message)s]" % - {"from_jid": from_jid.full(), "to_jid": to_jid.full(), "message": message}))) + extra = pickle.dumps({k: v for k, v in data['extra'].iteritems() if k not in NOT_IN_EXTRA}, 0) + from_jid = data['from'] + to_jid = data['to'] + d = self.dbpool.runQuery("INSERT INTO history(uid, update_uid, profile_id, source, dest, source_res, dest_res, timestamp, received_timestamp, type, extra) VALUES (?,?,?,?,?,?,?,?,?,?,?)", + (data['uid'], data['extra'].get('update_uid'), self.profiles[profile], data['from'].userhost(), to_jid.userhost(), from_jid.resource, to_jid.resource, data['timestamp'], data.get('received_timestamp'), data['type'], sqlite3.Binary(extra))) + d.addCallbacks(self._addToHistoryCb, self._addToHistoryEb, callbackArgs=[data], errbackArgs=[data]) + d.addErrback(self._logHistoryError, from_jid, to_jid, data) return d - def getHistory(self, from_jid, to_jid, limit=None, between=True, search=None, profile=None): + def sqliteHistoryToList(self, query_result): + """Get SQL query result and return a list of message data dicts""" + result = [] + current = {'uid': None} + for row in reversed(query_result): + uid, update_uid, source, dest, source_res, dest_res, timestamp, received_timestamp,\ + type_, extra, message, message_lang, subject, subject_lang, thread, thread_parent = row + if uid != current['uid']: + # new message + try: + extra = pickle.loads(str(extra or "")) + except EOFError: + extra = {} + current = { + 'from': "%s/%s" % (source, source_res) if source_res else source, + 'to': "%s/%s" % (dest, dest_res) if dest_res else dest, + 'uid': uid, + 'message': {}, + 'subject': {}, + 'type': type_, + 'extra': extra, + 'timestamp': timestamp, + } + if update_uid is not None: + current['extra']['update_uid'] = update_uid + if received_timestamp is not None: + current['extra']['received_timestamp'] = str(received_timestamp) + result.append(current) + + if message is not None: + current['message'][message_lang or ''] = message + + if subject is not None: + current['subject'][subject_lang or ''] = subject + + if thread is not None: + current_extra = current['extra'] + current_extra['thread'] = thread + if thread_parent is not None: + current_extra['thread_parent'] = thread_parent + else: + if thread_parent is not None: + log.error(u"Database inconsistency: thread parent without thread (uid: {uid}, thread_parent: {parent})" + .format(uid=uid, parent=thread_parent)) + + return result + + def listDict2listTuple(self, messages_data): + """Return a list of tuple as used in bridge from a list of messages data""" + ret = [] + for m in messages_data: + ret.append((m['uid'], m['timestamp'], m['from'], m['to'], m['message'], m['subject'], m['type'], m['extra'])) + return ret + + def historyGet(self, from_jid, to_jid, limit=None, between=True, search=None, profile=None): """Retrieve messages in history + @param from_jid (JID): source JID (full, or bare for catchall) @param to_jid (JID): dest JID (full, or bare for catchall) @param limit (int): maximum number of messages to get: - 0 for no message (returns the empty list) - None for unlimited @param between (bool): confound source and dest (ignore the direction) - @param search (str): pattern to filter the history results - @param profile (str): %(doc_profile)s - @return: list of tuple as in http://wiki.goffi.org/wiki/Bridge_API#getHistory + @param search (unicode): pattern to filter the history results + @param profile (unicode): %(doc_profile)s + @return: list of tuple as in [messageNew] """ - assert(profile) + assert profile if limit == 0: return defer.succeed([]) - def sqliteToList(query_result): - query_result.reverse() - result = [] - for row in query_result: - timestamp, source, source_res, dest, dest_res, message, type_, extra_raw = row - try: - extra = pickle.loads(str(extra_raw or "")) - except EOFError: - extra = {} - result.append((timestamp, "%s/%s" % (source, source_res) if source_res else source, - "%s/%s" % (dest, dest_res) if dest_res else dest, - message, type_, extra)) - return result - - query_parts = ["SELECT timestamp, source, source_res, dest, dest_res, message, type, extra FROM history WHERE profile_id=? AND"] + query_parts = ["SELECT uid, update_uid, source, dest, source_res, dest_res, timestamp, received_timestamp,\ + type, extra, message, message.language, subject, subject.language, thread_id, thread.parent_id\ + FROM history LEFT JOIN message ON history.uid = message.history_uid\ + LEFT JOIN subject ON history.uid=subject.history_uid\ + LEFT JOIN thread ON history.uid=thread.history_uid\ + WHERE profile_id=? AND"] # FIXME: not sure if it's the best request, messages and subjects can appear several times here values = [self.profiles[profile]] def test_jid(type_, _jid): @@ -324,14 +437,16 @@ query_parts.append("AND message GLOB ?") values.append("*%s*" % search) - query_parts.append("ORDER BY timestamp DESC") - + query_parts.append("ORDER BY timestamp DESC") # we reverse the order in sqliteHistoryToList + # we use DESC here so LIMIT keep the last messages if limit is not None: query_parts.append("LIMIT ?") values.append(limit) d = self.dbpool.runQuery(" ".join(query_parts), values) - return d.addCallback(sqliteToList) + d.addCallback(self.sqliteHistoryToList) + d.addCallback(self.listDict2listTuple) + return d #Private values def loadGenPrivates(self, private_gen, namespace): @@ -444,7 +559,7 @@ @param key: key of the private value @param value: value to set @return: deferred""" - d = self.dbpool.runQuery("REPLACE INTO private_gen_bin(namespace,key,value) VALUES (?,?,?)", (namespace, key, pickle.dumps(value, 0))) + d = self.dbpool.runQuery("REPLACE INTO private_gen_bin(namespace,key,value) VALUES (?,?,?)", (namespace, key, sqlite3.Binary(pickle.dumps(value, 0)))) d.addErrback(lambda ignore: log.error(_(u"Can't set general private binary value (%(key)s) [namespace:%(namespace)s] in database" % {"namespace": namespace, "key": key}))) return d @@ -456,7 +571,7 @@ @param value: value to set @param profile: a profile which *must* exist @return: deferred""" - d = self.dbpool.runQuery("REPLACE INTO private_ind_bin(namespace,key,profile_id,value) VALUES (?,?,?,?)", (namespace, key, self.profiles[profile], pickle.dumps(value, 0))) + d = self.dbpool.runQuery("REPLACE INTO private_ind_bin(namespace,key,profile_id,value) VALUES (?,?,?,?)", (namespace, key, self.profiles[profile], sqlite3.Binary(pickle.dumps(value, 0)))) d.addErrback(lambda ignore: log.error(_(u"Can't set individual binary private value (%(key)s) [namespace: %(namespace)s] for [%(profile)s] in database" % {"namespace": namespace, "key": key, "profile": profile}))) return d @@ -665,6 +780,7 @@ def generateUpdateData(self, old_data, new_data, modify=False): """ Generate data for automatic update between two schema data + @param old_data: data of the former schema (which must be updated) @param new_data: data of the current schema @param modify: if True, always use "cols modify" table, else try to ALTER tables @@ -728,10 +844,10 @@ @defer.inlineCallbacks def update2raw(self, update, dev_version=False): """ Transform update data to raw SQLite statements + @param update: update data as returned by generateUpdateData @param dev_version: if True, update will be done in dev mode: no deletion will be done, instead a message will be shown. This prevent accidental lost of data while working on the code/database. @return: list of string with SQL statements needed to update the base - """ ret = self.createData2Raw(update.get('create', {})) drop = [] @@ -767,11 +883,104 @@ specific = update.get('specific', None) if specific: cmds = yield getattr(self, specific)() - ret.extend(cmds) + ret.extend(cmds or []) defer.returnValue(ret) + @defer.inlineCallbacks + def update_v3(self): + """Update database from v2 to v3 (message refactoring)""" + # XXX: this update do all the messages in one huge transaction + # this is really memory consuming, but was OK on a reasonably + # big database for tests. If issues are happening, we can cut it + # in smaller transactions using LIMIT and by deleting already updated + # messages + log.info(u"Database update to v3, this may take a while") + + # we need to fix duplicate timestamp, as it can result in conflicts with the new schema + rows = yield self.dbpool.runQuery("SELECT timestamp, COUNT(*) as c FROM history GROUP BY timestamp HAVING c>1") + if rows: + log.info("fixing duplicate timestamp") + fixed = [] + for timestamp, dummy in rows: + ids_rows = yield self.dbpool.runQuery("SELECT id from history where timestamp=?", (timestamp,)) + for idx, (id_,) in enumerate(ids_rows): + fixed.append(id_) + yield self.dbpool.runQuery("UPDATE history SET timestamp=? WHERE id=?", (float(timestamp) + idx * 0.001, id_)) + log.info(u"fixed messages with ids {}".format(u', '.join([unicode(id_) for id_ in fixed]))) + + def historySchema(txn): + log.info(u"History schema update") + txn.execute("ALTER TABLE history RENAME TO tmp_sat_update") + txn.execute("CREATE TABLE history (uid TEXT PRIMARY KEY, update_uid TEXT, profile_id INTEGER, source TEXT, dest TEXT, source_res TEXT, dest_res TEXT, timestamp DATETIME NOT NULL, received_timestamp DATETIME, type TEXT, extra BLOB, FOREIGN KEY(profile_id) REFERENCES profiles(id) ON DELETE CASCADE, FOREIGN KEY(type) REFERENCES message_types(type), UNIQUE (profile_id, timestamp, source, dest, source_res, dest_res))") + txn.execute("INSERT INTO history (uid, profile_id, source, dest, source_res, dest_res, timestamp, type, extra) SELECT id, profile_id, source, dest, source_res, dest_res, timestamp, type, extra FROM tmp_sat_update") + + yield self.dbpool.runInteraction(historySchema) + + def newTables(txn): + log.info(u"Creating new tables") + txn.execute("CREATE TABLE message (id INTEGER PRIMARY KEY ASC, history_uid INTEGER, message TEXT, language TEXT, FOREIGN KEY(history_uid) REFERENCES history(uid) ON DELETE CASCADE)") + txn.execute("CREATE TABLE thread (id INTEGER PRIMARY KEY ASC, history_uid INTEGER, thread_id TEXT, parent_id TEXT, FOREIGN KEY(history_uid) REFERENCES history(uid) ON DELETE CASCADE)") + txn.execute("CREATE TABLE subject (id INTEGER PRIMARY KEY ASC, history_uid INTEGER, subject TEXT, language TEXT, FOREIGN KEY(history_uid) REFERENCES history(uid) ON DELETE CASCADE)") + + yield self.dbpool.runInteraction(newTables) + + log.info(u"inserting new message type") + yield self.dbpool.runQuery("INSERT INTO message_types VALUES (?)", ('info',)) + + log.info(u"messages update") + rows = yield self.dbpool.runQuery("SELECT id, timestamp, message, extra FROM tmp_sat_update") + total = len(rows) + + def updateHistory(txn, queries): + for query, args in iter(queries): + txn.execute(query, args) + + queries = [] + for idx, row in enumerate(rows, 1): + if idx % 1000 == 0 or total - idx == 0: + log.info("preparing message {}/{}".format(idx, total)) + id_, timestamp, message, extra = row + try: + extra = pickle.loads(str(extra or "")) + except EOFError: + extra = {} + except Exception: + log.warning(u"Can't handle extra data for message id {}, ignoring it".format(id_)) + extra = {} + + queries.append(("INSERT INTO message(history_uid, message) VALUES (?,?)", (id_, message))) + + try: + subject = extra.pop('subject') + except KeyError: + pass + else: + try: + subject = subject.decode('utf-8') + except UnicodeEncodeError: + log.warning(u"Error while decoding subject, ignoring it") + del extra['subject'] + else: + queries.append(("INSERT INTO subject(history_uid, subject) VALUES (?,?)", (id_, subject))) + + received_timestamp = extra.pop('timestamp', None) + try: + del extra['archive'] + except KeyError: + # archive was not used + pass + + queries.append(("UPDATE history SET received_timestamp=?,extra=? WHERE uid=?",(id_, received_timestamp, sqlite3.Binary(pickle.dumps(extra, 0))))) + + yield self.dbpool.runInteraction(updateHistory, queries) + + log.info("Dropping temporary table") + yield self.dbpool.runQuery("DROP TABLE tmp_sat_update") + log.info("Database update finished :)") + def update2raw_v2(self): """Update the database from v1 to v2 (add passwords encryptions): + - the XMPP password value is re-used for the profile password (new parameter) - the profile password is stored hashed - the XMPP password is stored encrypted, with the profile password as key
--- a/src/plugins/plugin_exp_command_export.py Fri Jun 24 22:32:58 2016 +0200 +++ b/src/plugins/plugin_exp_command_export.py Fri Jun 24 22:41:28 2016 +0200 @@ -18,6 +18,7 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. from sat.core.i18n import _ +from sat.core.constants import Const as C from sat.core.log import getLogger log = getLogger(__name__) from twisted.words.protocols.jabber import jid @@ -57,10 +58,10 @@ log.info("connectionMade :)") def outReceived(self, data): - self.parent.host.sendMessage(self.target, self._clean(data), no_trigger=True, profile_key=self.profile) + self.parent.host.messageSend(self.target, {'': self._clean(data)}, no_trigger=True, profile_key=self.profile) def errReceived(self, data): - self.parent.host.sendMessage(self.target, self._clean(data), no_trigger=True, profile_key=self.profile) + self.parent.host.messageSend(self.target, {'': self._clean(data)}, no_trigger=True, profile_key=self.profile) def processEnded(self, reason): log.info (u"process finished: %d" % (reason.value.exitCode,)) @@ -102,20 +103,19 @@ except ValueError: pass - def MessageReceivedTrigger(self, message, post_treat, profile): + def MessageReceivedTrigger(self, client, message_elt, post_treat): """ Check if source is linked and repeat message, else do nothing """ - from_jid = jid.JID(message["from"]) - spawned_key = (from_jid.userhostJID(), profile) - try: - body = [e for e in message.elements() if e.name == 'body'][0] - except IndexError: - # do not block message without body (chat state notification...) - log.debug("No body element found in message, following normal behaviour") - return True - - mess_data = unicode(body) + '\n' + from_jid = jid.JID(message_elt["from"]) + spawned_key = (from_jid.userhostJID(), client.profile) if spawned_key in self.spawned: + try: + body = message_elt.elements(C.NS_CLIENT, 'body').next() + except StopIteration: + # do not block message without body (chat state notification...) + return True + + mess_data = unicode(body) + '\n' processes_set = self.spawned[spawned_key] _continue = False exclusive = False
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/plugins/plugin_exp_lang_detect.py Fri Jun 24 22:41:28 2016 +0200 @@ -0,0 +1,68 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin to detect language (experimental) +# Copyright (C) 2009-2016 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from sat.core.i18n import _ +from sat.core.log import getLogger +log = getLogger(__name__) +from sat.core import exceptions + +try: + from langid.langid import LanguageIdentifier, model +except ImportError: + raise exceptions.MissingModule(u'Missing module langid, please download/install it with "pip install langid")') + +identifier = LanguageIdentifier.from_modelstring(model, norm_probs=False) + + +PLUGIN_INFO = { + "name": "Language detection plugin", + "import_name": "EXP-LANG-DETECT", + "type": "EXP", + "protocols": [], + "dependencies": [], + "main": "LangDetect", + "handler": "no", + "description": _("""Detect and set message language when unknown""") +} + + +class LangDetect(object): + + def __init__(self, host): + log.info(_(u"Language detection plugin initialization")) + self.host = host + host.trigger.add("MessageReceived", self.MessageReceivedTrigger) + host.trigger.add("messageSend", self.MessageSendTrigger) + + def addLanguage(self, mess_data): + message = mess_data['message'] + if len(message) == 1 and message.keys()[0] == '': + msg = message.values()[0] + lang = identifier.classify(msg)[0] + mess_data["message"] = {lang: msg} + return mess_data + + def MessageReceivedTrigger(self, client, message_elt, post_treat): + """ Check if source is linked and repeat message, else do nothing """ + post_treat.addCallback(self.addLanguage) + return True + + def MessageSendTrigger(self, client, data, pre_xml_treatments, post_xml_treatments): + self.addLanguage(data) + return True
--- a/src/plugins/plugin_exp_parrot.py Fri Jun 24 22:32:58 2016 +0200 +++ b/src/plugins/plugin_exp_parrot.py Fri Jun 24 22:41:28 2016 +0200 @@ -43,22 +43,21 @@ """Parrot mode plugin: repeat messages from one entity or MUC room to another one""" #XXX: This plugin can be potentially dangerous if we don't trust entities linked # this is specially true if we have other triggers. - # sendMessageTrigger avoid other triggers execution, it's deactivated to allow + # messageSendTrigger avoid other triggers execution, it's deactivated to allow # /unparrot command in text commands plugin. def __init__(self, host): log.info(_("Plugin Parrot initialization")) self.host = host host.trigger.add("MessageReceived", self.MessageReceivedTrigger, priority=100) - #host.trigger.add("sendMessage", self.sendMessageTrigger, priority=100) + #host.trigger.add("messageSend", self.messageSendTrigger, priority=100) try: self.host.plugins[C.TEXT_CMDS].registerTextCommands(self) except KeyError: log.info(_("Text commands not available")) - #def sendMessageTrigger(self, mess_data, treatments, profile): + #def messageSendTrigger(self, client, mess_data, treatments): # """ Deactivate other triggers if recipient is in parrot links """ - # client = self.host.getClient(profile) # try: # _links = client.parrot_links # except AttributeError: @@ -68,10 +67,12 @@ # log.debug("Parrot link detected, skipping other triggers") # raise trigger.SkipOtherTriggers - def MessageReceivedTrigger(self, message, post_treat, profile): + def MessageReceivedTrigger(self, client, message_elt, post_treat): """ Check if source is linked and repeat message, else do nothing """ + # TODO: many things are not repeated (subject, thread, etc) + profile = client.profile client = self.host.getClient(profile) - from_jid = jid.JID(message["from"]) + from_jid = message_elt["from"] try: _links = client.parrot_links @@ -81,37 +82,36 @@ if not from_jid.userhostJID() in _links: return True - for e in message.elements(): - if e.name == "body": - mess_body = e.children[0] if e.children else "" + message = {} + for e in message_elt.elements(C.NS_CLIENT, 'body'): + body = unicode(e) + lang = e.getAttribute('lang') or '' - try: - entity_type = self.host.memory.getEntityData(from_jid, ['type'], profile)["type"] - except (UnknownEntityError, KeyError): - entity_type = "contact" - if entity_type == 'chatroom': - src_txt = from_jid.resource - if src_txt == self.host.plugins["XEP-0045"].getRoomNick(from_jid.userhostJID(), profile): - #we won't repeat our own messages - return True - else: - src_txt = from_jid.user - msg = "[%s] %s" % (src_txt, mess_body) + try: + entity_type = self.host.memory.getEntityData(from_jid, ['type'], profile)["type"] + except (UnknownEntityError, KeyError): + entity_type = "contact" + if entity_type == 'chatroom': + src_txt = from_jid.resource + if src_txt == self.host.plugins["XEP-0045"].getRoomNick(from_jid.userhostJID(), profile): + #we won't repeat our own messages + return True + else: + src_txt = from_jid.user + message[lang] = u"[{}] {}".format(src_txt, body) - linked = _links[from_jid.userhostJID()] + linked = _links[from_jid.userhostJID()] - self.host.sendMessage(jid.JID(unicode(linked)), msg, None, "auto", no_trigger=True, profile_key=profile) - else: - log.warning("No body element found in message, following normal behaviour") + self.host.messageSend(jid.JID(unicode(linked)), message, None, "auto", no_trigger=True, profile_key=profile) return True - def addParrot(self, source_jid, dest_jid, profile): + def addParrot(self, client, source_jid, dest_jid): """Add a parrot link from one entity to another one + @param source_jid: entity from who messages will be repeated @param dest_jid: entity where the messages will be repeated - @param profile: %(doc_profile_key)s""" - client = self.host.getClient(profile) + """ try: _links = client.parrot_links except AttributeError: @@ -120,17 +120,17 @@ _links[source_jid.userhostJID()] = dest_jid log.info(u"Parrot mode: %s will be repeated to %s" % (source_jid.userhost(), unicode(dest_jid))) - def removeParrot(self, source_jid, profile): + def removeParrot(self, client, source_jid): """Remove parrot link + @param source_jid: this entity will no more be repeated - @param profile: %(doc_profile_key)s""" - client = self.host.getClient(profile) + """ try: del client.parrot_links[source_jid.userhostJID()] except (AttributeError, KeyError): pass - def cmd_parrot(self, mess_data, profile): + def cmd_parrot(self, client, mess_data): """activate Parrot mode between 2 entities, in both directions.""" log.debug("Catched parrot command") txt_cmd = self.host.plugins[C.TEXT_CMDS] @@ -140,19 +140,19 @@ if not link_left_jid.user or not link_left_jid.host: raise jid.InvalidFormat except (RuntimeError, jid.InvalidFormat, AttributeError): - txt_cmd.feedBack("Can't activate Parrot mode for invalid jid", mess_data, profile) + txt_cmd.feedBack(client, "Can't activate Parrot mode for invalid jid", mess_data) return False link_right_jid = mess_data['to'] - self.addParrot(link_left_jid, link_right_jid, profile) - self.addParrot(link_right_jid, link_left_jid, profile) + self.addParrot(client, link_left_jid, link_right_jid) + self.addParrot(client, link_right_jid, link_left_jid) - txt_cmd.feedBack("Parrot mode activated for %s" % (unicode(link_left_jid), ), mess_data, profile) + txt_cmd.feedBack(client, "Parrot mode activated for {}".format(unicode(link_left_jid)), mess_data) return False - def cmd_unparrot(self, mess_data, profile): + def cmd_unparrot(self, client, mess_data): """remove Parrot mode between 2 entities, in both directions.""" log.debug("Catched unparrot command") txt_cmd = self.host.plugins[C.TEXT_CMDS] @@ -162,14 +162,14 @@ if not link_left_jid.user or not link_left_jid.host: raise jid.InvalidFormat except jid.InvalidFormat: - txt_cmd.feedBack("Can't deactivate Parrot mode for invalid jid", mess_data, profile) + txt_cmd.feedBack(client, u"Can't deactivate Parrot mode for invalid jid", mess_data) return False link_right_jid = mess_data['to'] - self.removeParrot(link_left_jid, profile) - self.removeParrot(link_right_jid, profile) + self.removeParrot(client, link_left_jid) + self.removeParrot(client, link_right_jid) - txt_cmd.feedBack("Parrot mode deactivated for %s and %s" % (unicode(link_left_jid), unicode(link_right_jid)), mess_data, profile) + txt_cmd.feedBack(client, u"Parrot mode deactivated for {} and {}".format(unicode(link_left_jid), unicode(link_right_jid)), mess_data) return False
--- a/src/plugins/plugin_misc_imap.py Fri Jun 24 22:32:58 2016 +0200 +++ b/src/plugins/plugin_misc_imap.py Fri Jun 24 22:41:28 2016 +0200 @@ -154,11 +154,11 @@ log.debug(u'Mailbox init (%s)' % name) if name != "INBOX": raise imap4.MailboxException("Only INBOX is managed for the moment") - self.mailbox = self.host.plugins["Maildir"].accessMessageBox(name, self.newMessage, profile) + self.mailbox = self.host.plugins["Maildir"].accessMessageBox(name, self.messageNew, profile) - def newMessage(self): + def messageNew(self): """Called when a new message is in the mailbox""" - log.debug("newMessage signal received") + log.debug("messageNew signal received") nb_messages = self.getMessageCount() for listener in self.listeners: listener.newMessages(nb_messages, None)
--- a/src/plugins/plugin_misc_maildir.py Fri Jun 24 22:32:58 2016 +0200 +++ b/src/plugins/plugin_misc_maildir.py Fri Jun 24 22:41:28 2016 +0200 @@ -98,18 +98,19 @@ del self.__mailboxes[profile] del self.data[profile] - def messageReceivedTrigger(self, message, post_treat, profile): + def messageReceivedTrigger(self, client, message, post_treat): """This trigger catch normal message and put the in the Maildir box. If the message is not of "normal" type, do nothing @param message: message xmlstrem @return: False if it's a normal message, True else""" - for e in message.elements(): - if e.name == "body": - mess_type = message['type'] if message.hasAttribute('type') else 'normal' - if mess_type != 'normal': - return True - self.accessMessageBox("INBOX", profile_key=profile).addMessage(message) - return not self.host.memory.getParamA(NAME, CATEGORY, profile_key=profile) + profile = client.profile + for e in message.elements(C.NS_CLIENT, 'body'): + mess_type = message.getAttribute('type', 'normal') + if mess_type != 'normal': + return True + self.accessMessageBox("INBOX", profile_key=profile).addMessage(message) + return not self.host.memory.getParamA(NAME, CATEGORY, profile_key=profile) + return True def accessMessageBox(self, boxname, observer=None, profile_key=C.PROF_KEY_NONE): """Create and return a MailboxUser instance
--- a/src/plugins/plugin_misc_smtp.py Fri Jun 24 22:32:58 2016 +0200 +++ b/src/plugins/plugin_misc_smtp.py Fri Jun 24 22:41:28 2016 +0200 @@ -87,7 +87,7 @@ """handle end of message""" mail = Parser().parsestr("\n".join(self.message)) try: - self.host._sendMessage(parseaddr(mail['to'].decode('utf-8', 'replace'))[1], mail.get_payload().decode('utf-8', 'replace'), # TODO: manage other charsets + self.host._messageSend(parseaddr(mail['to'].decode('utf-8', 'replace'))[1], mail.get_payload().decode('utf-8', 'replace'), # TODO: manage other charsets subject=mail['subject'].decode('utf-8', 'replace'), mess_type='normal', profile_key=self.profile) except: exc_type, exc_value, exc_traceback = sys.exc_info()
--- a/src/plugins/plugin_misc_text_commands.py Fri Jun 24 22:32:58 2016 +0200 +++ b/src/plugins/plugin_misc_text_commands.py Fri Jun 24 22:41:28 2016 +0200 @@ -46,6 +46,7 @@ CMD_KEY = "@command" CMD_TYPES = ('group', 'one2one', 'all') +FEEDBACK_INFO_TYPE = "TEXT_CMD" class TextCommands(object): @@ -58,7 +59,7 @@ def __init__(self, host): log.info(_("Text commands initialization")) self.host = host - host.trigger.add("sendMessage", self.sendMessageTrigger) + host.trigger.add("messageSend", self.messageSendTrigger) self._commands = {} self._whois = [] self.registerTextCommands(self) @@ -148,7 +149,7 @@ while (cmd_name + str(suff)) in self._commands: suff+=1 new_name = cmd_name + str(suff) - log.warning(_(u"Conflict for command [%(old_name)s], renaming it to [%(new_name)s]") % {'old_name': cmd_name, 'new_name': new_name}) + log.warning(_(u"Conflict for command [{old_name}], renaming it to [{new_name}]").format(old_name=cmd_name, new_name=new_name)) cmd_name = new_name self._commands[cmd_name] = cmd_data = OrderedDict({'callback':cmd}) # We use an Ordered dict to keep documenation order cmd_data.update(self._parseDocString(cmd, cmd_name)) @@ -166,12 +167,12 @@ self._whois.append((priority, callback)) self._whois.sort(key=lambda item: item[0], reverse=True) - def sendMessageTrigger(self, mess_data, pre_xml_treatments, post_xml_treatments, profile): - """ Install SendMessage command hook """ - pre_xml_treatments.addCallback(self._sendMessageCmdHook, profile) + def messageSendTrigger(self, client, mess_data, pre_xml_treatments, post_xml_treatments): + """Install SendMessage command hook """ + pre_xml_treatments.addCallback(self._messageSendCmdHook, client) return True - def _sendMessageCmdHook(self, mess_data, profile): + def _messageSendCmdHook(self, mess_data, client): """ Check text commands in message, and react consequently msg starting with / are potential command. If a command is found, it is executed, else and help message is sent @@ -179,14 +180,24 @@ commands can abord message sending (if they return anything evaluating to False), or continue it (if they return True), eventually after modifying the message an "unparsed" key is added to message, containing part of the message not yet parsed commands can be deferred or not - @param mess_data(dict): data comming from sendMessage trigger + @param mess_data(dict): data comming from messageSend trigger @param profile: %(doc_profile)s """ - msg = mess_data["message"] + try: + msg = mess_data["message"][''] + msg_lang = '' + except KeyError: + try: + # we have not default message, we try to take the first found + msg_lang, msg = mess_data["message"].iteritems().next() + except StopIteration: + log.debug(u"No message found, skipping text commands") + return mess_data + try: if msg[:2] == '//': # we have a double '/', it's the escape sequence - mess_data["message"] = msg[1:] + mess_data["message"][msg_lang] = msg[1:] return mess_data if msg[0] != '/': return mess_data @@ -206,7 +217,7 @@ if ret: return mess_data else: - log.debug("text commands took over") + log.debug(u"text command detected ({})".format(command)) raise failure.Failure(exceptions.CancelError()) def genericErrback(failure): @@ -214,26 +225,26 @@ msg = u"with condition {}".format(failure.value.condition) except AttributeError: msg = u"with error {}".format(failure.value) - self.feedBack(u"Command failed {}".format(msg), mess_data, profile) + self.feedBack(client, u"Command failed {}".format(msg), mess_data) return False mess_data["unparsed"] = msg[1 + len(command):] # part not yet parsed of the message try: cmd_data = self._commands[command] except KeyError: - self.feedBack(_("Unknown command /%s. ") % command + self.HELP_SUGGESTION, mess_data, profile) - log.debug("text commands took over") + self.feedBack(client, _("Unknown command /%s. ") % command + self.HELP_SUGGESTION, mess_data) + log.debug("text command help message") raise failure.Failure(exceptions.CancelError()) else: if not self._contextValid(mess_data, cmd_data): # The command is not launched in the right context, we throw a message with help instructions context_txt = _("group discussions") if cmd_data["type"] == "group" else _("one to one discussions") feedback = _("/{command} command only applies in {context}.").format(command=command, context=context_txt) - self.feedBack(u"{} {}".format(feedback, self.HELP_SUGGESTION), mess_data, profile) - log.debug("text commands took over") + self.feedBack(client, u"{} {}".format(feedback, self.HELP_SUGGESTION), mess_data) + log.debug("text command invalid message") raise failure.Failure(exceptions.CancelError()) else: - d = defer.maybeDeferred(cmd_data["callback"], mess_data, profile) + d = defer.maybeDeferred(cmd_data["callback"], client, mess_data) d.addErrback(genericErrback) d.addCallback(retHandling) @@ -242,7 +253,7 @@ def _contextValid(self, mess_data, cmd_data): """Tell if a command can be used in the given context - @param mess_data(dict): message data as given in sendMessage trigger + @param mess_data(dict): message data as given in messageSend trigger @param cmd_data(dict): command data as returned by self._parseDocString @return (bool): True if command can be used in this context """ @@ -265,16 +276,22 @@ return jid.JID(arg + service_jid) return jid.JID(u"%s@%s" % (arg, service_jid)) - def feedBack(self, message, mess_data, profile): + def feedBack(self, client, message, mess_data, info_type=FEEDBACK_INFO_TYPE): """Give a message back to the user""" if mess_data["type"] == 'groupchat': - _from = mess_data["to"].userhostJID() + to_ = mess_data["to"].userhostJID() else: - _from = self.host.getJidNStream(profile)[0] + to_ = client.jid - self.host.bridge.newMessage(unicode(mess_data["to"]), message, C.MESS_TYPE_INFO, unicode(_from), {}, profile=profile) + # we need to invert send message back, so sender need to original destinee + mess_data["from"] = mess_data["to"] + mess_data["to"] = to_ + mess_data["type"] = C.MESS_TYPE_INFO + mess_data["message"] = {'': message} + mess_data["extra"]["info_type"] = info_type + self.host.messageSendToBridge(mess_data, client) - def cmd_whois(self, mess_data, profile): + def cmd_whois(self, client, mess_data): """show informations on entity @command: [JID|ROOM_NICK] @@ -288,7 +305,7 @@ if mess_data['type'] == "groupchat": room = mess_data["to"].userhostJID() try: - if self.host.plugins["XEP-0045"].isNickInRoom(room, entity, profile): + if self.host.plugins["XEP-0045"].isNickInRoom(room, entity, client.profile): entity = u"%s/%s" % (room, entity) except KeyError: log.warning("plugin XEP-0045 is not present") @@ -301,20 +318,20 @@ if not target_jid.user or not target_jid.host: raise jid.InvalidFormat except (RuntimeError, jid.InvalidFormat, AttributeError): - self.feedBack(_("Invalid jid, can't whois"), mess_data, profile) + self.feedBack(client, _("Invalid jid, can't whois"), mess_data) return False if not target_jid.resource: - target_jid.resource = self.host.memory.getMainResource(target_jid, profile) + target_jid.resource = self.host.memory.getMainResource(target_jid, client.profile) whois_msg = [_(u"whois for %(jid)s") % {'jid': target_jid}] d = defer.succeed(None) for ignore, callback in self._whois: - d.addCallback(lambda ignore: callback(whois_msg, mess_data, target_jid, profile)) + d.addCallback(lambda ignore: callback(client, whois_msg, mess_data, target_jid)) def feedBack(ignore): - self.feedBack(u"\n".join(whois_msg), mess_data, profile) + self.feedBack(client, u"\n".join(whois_msg), mess_data) return False d.addCallback(feedBack) @@ -334,7 +351,7 @@ return strings - def cmd_help(self, mess_data, profile): + def cmd_help(self, client, mess_data): """show help on available commands @command: [cmd_name] @@ -344,7 +361,7 @@ if cmd_name and cmd_name[0] == "/": cmd_name = cmd_name[1:] if cmd_name and cmd_name not in self._commands: - self.feedBack(_(u"Invalid command name [{}]\n".format(cmd_name)), mess_data, profile) + self.feedBack(client, _(u"Invalid command name [{}]\n".format(cmd_name)), mess_data) cmd_name = "" if not cmd_name: # we show the global help @@ -373,13 +390,4 @@ syntax=_(" "*4+"syntax: {}\n").format(syntax) if syntax else "", args_help=u'\n'.join([u" "*8+"{}".format(line) for line in self._getArgsHelp(cmd_data)])) - self.feedBack(help_mess, mess_data, profile) - - def cmd_me(self, mess_data, profile): - """Display a message at third person - - @command: message - - message: message to display at the third person - """ - # We just catch the method and continue it as the frontends should manage /me display - return True + self.feedBack(client, help_mess, mess_data)
--- a/src/plugins/plugin_sec_otr.py Fri Jun 24 22:32:58 2016 +0200 +++ b/src/plugins/plugin_sec_otr.py Fri Jun 24 22:41:28 2016 +0200 @@ -32,6 +32,8 @@ from sat.memory import persistent import potr import copy +import time +import uuid NS_OTR = "otr_plugin" PRIVATE_KEY = "PRIVATE KEY" @@ -73,11 +75,15 @@ msg = msg_str.decode('utf-8') client = self.user.client log.debug(u'inject(%s, appdata=%s, to=%s)' % (msg, appdata, self.peer)) - mess_data = {'message': msg, - 'type': 'chat', + mess_data = { 'from': client.jid, - 'to': self.peer, - 'subject': None, + 'to': self.peer, + 'uid': unicode(uuid.uuid4()), + 'message': {'': msg}, + 'subject': {}, + 'type': 'chat', + 'extra': {}, + 'timestamp': time.time(), } self.host.generateMessageXML(mess_data) client.xmlstream.send(mess_data['xml']) @@ -107,7 +113,7 @@ return client = self.user.client - self.host.bridge.newMessage(client.jid.full(), + self.host.bridge.messageNew(client.jid.full(), feedback, mess_type=C.MESS_TYPE_INFO, to_jid=self.peer.full(), @@ -202,8 +208,8 @@ self.context_managers = {} self.skipped_profiles = set() host.trigger.add("MessageReceived", self.MessageReceivedTrigger, priority=100000) - host.trigger.add("sendMessage", self.sendMessageTrigger, priority=100000) - host.bridge.addMethod("skipOTR", ".plugin", in_sign='s', out_sign='', method=self._skipOTR) + host.trigger.add("messageSend", self.messageSendTrigger, priority=100000) + host.bridge.addMethod("skipOTR", ".plugin", in_sign='s', out_sign='', method=self._skipOTR) # FIXME: must be removed, must be done on per-message basis host.importMenu((MAIN_MENU, D_("Start/Refresh")), self._startRefresh, security_limit=0, help_string=D_("Start or refresh an OTR session"), type_=C.MENU_SINGLE) host.importMenu((MAIN_MENU, D_("End session")), self._endSession, security_limit=0, help_string=D_("Finish an OTR session"), type_=C.MENU_SINGLE) host.importMenu((MAIN_MENU, D_("Authenticate")), self._authenticate, security_limit=0, help_string=D_("Authenticate user/see your fingerprint"), type_=C.MENU_SINGLE) @@ -231,6 +237,9 @@ @param profile (str): %(doc_profile)s """ + # FIXME: should not be done per profile but per message, using extra data + # for message received, profile wide hook may be need, but client + # should be used anyway instead of a class attribute self.skipped_profiles.add(profile) @defer.inlineCallbacks @@ -275,7 +284,7 @@ log.error(_("jid key is not present !")) return defer.fail(exceptions.DataError) otrctx = self.context_managers[profile].getContextForUser(to_jid) - query = otrctx.sendMessage(0, '?OTRv?') + query = otrctx.messageSend(0, '?OTRv?') otrctx.inject(query) return {} @@ -400,24 +409,27 @@ return {'xmlui': confirm.toXml()} def _receivedTreatment(self, data, profile): - from_jid = jid.JID(data['from']) + from_jid = data['from'] log.debug(u"_receivedTreatment [from_jid = %s]" % from_jid) otrctx = self.context_managers[profile].getContextForUser(from_jid) encrypted = True try: - res = otrctx.receiveMessage(data['body'].encode('utf-8')) + message = data['message'].itervalues().next() # FIXME: Q&D fix for message refactoring, message is now a dict + res = otrctx.receiveMessage(message.encode('utf-8')) except potr.context.UnencryptedMessage: if otrctx.state == potr.context.STATE_ENCRYPTED: log.warning(u"Received unencrypted message in an encrypted context (from %(jid)s)" % {'jid': from_jid.full()}) client = self.host.getClient(profile) - self.host.bridge.newMessage(from_jid.full(), + self.host.bridge.messageNew(from_jid.full(), _(u"WARNING: received unencrypted data in a supposedly encrypted context"), mess_type=C.MESS_TYPE_INFO, to_jid=client.jid.full(), extra={}, profile=client.profile) encrypted = False + except StopIteration: + return data if not encrypted: return data @@ -425,27 +437,32 @@ if res[0] != None: # decrypted messages handling. # receiveMessage() will return a tuple, the first part of which will be the decrypted message - data['body'] = res[0].decode('utf-8') + data['message'] = {'':res[0].decode('utf-8')} # FIXME: Q&D fix for message refactoring, message is now a dict raise failure.Failure(exceptions.SkipHistory()) # we send the decrypted message to frontends, but we don't want it in history else: - raise failure.Failure(exceptions.CancelError()) # no message at all (no history, no signal) + raise failure.Failure(exceptions.CancelError('Cancelled by OTR')) # no message at all (no history, no signal) def _receivedTreatmentForSkippedProfiles(self, data, profile): """This profile must be skipped because the frontend manages OTR itself, but we still need to check if the message must be stored in history or not""" - body = data['body'].encode('utf-8') - if body.startswith(potr.proto.OTRTAG): + try: + message = data['message'].itervalues().next().encode('utf-8') # FIXME: Q&D fix for message refactoring, message is now a dict + except StopIteration: + return data + if message.startswith(potr.proto.OTRTAG): raise failure.Failure(exceptions.SkipHistory()) return data - def MessageReceivedTrigger(self, message, post_treat, profile): + def MessageReceivedTrigger(self, client, message_elt, post_treat): + profile = client.profile if profile in self.skipped_profiles: post_treat.addCallback(self._receivedTreatmentForSkippedProfiles, profile) else: post_treat.addCallback(self._receivedTreatment, profile) return True - def sendMessageTrigger(self, mess_data, pre_xml_treatments, post_xml_treatments, profile): + def messageSendTrigger(self, client, mess_data, pre_xml_treatments, post_xml_treatments): + profile = client.profile if profile in self.skipped_profiles: return True to_jid = copy.copy(mess_data['to']) @@ -455,13 +472,19 @@ if mess_data['type'] != 'groupchat' and otrctx.state != potr.context.STATE_PLAINTEXT: if otrctx.state == potr.context.STATE_ENCRYPTED: log.debug(u"encrypting message") - otrctx.sendMessage(0, mess_data['message'].encode('utf-8')) - client = self.host.getClient(profile) - self.host.sendMessageToBridge(mess_data, client) + try: + msg = mess_data['message'][''] + except KeyError: + try: + msg = mess_data['message'].itervalues().next() + except StopIteration: + log.warning(u"No message found") + return False + otrctx.sendMessage(0, msg.encode('utf-8')) + self.host.messageSendToBridge(mess_data, client) else: feedback = D_("Your message was not sent because your correspondent closed the encrypted conversation on his/her side. Either close your own side, or refresh the session.") - client = self.host.getClient(profile) - self.host.bridge.newMessage(to_jid.full(), + self.host.bridge.messageNew(to_jid.full(), feedback, mess_type=C.MESS_TYPE_INFO, to_jid=client.jid.full(),
--- a/src/plugins/plugin_xep_0033.py Fri Jun 24 22:32:58 2016 +0200 +++ b/src/plugins/plugin_xep_0033.py Fri Jun 24 22:41:28 2016 +0200 @@ -72,11 +72,12 @@ log.info(_("Extended Stanza Addressing plugin initialization")) self.host = host self.internal_data = {} - host.trigger.add("sendMessage", self.sendMessageTrigger, trigger.TriggerManager.MIN_PRIORITY) + host.trigger.add("messageSend", self.messageSendTrigger, trigger.TriggerManager.MIN_PRIORITY) host.trigger.add("MessageReceived", self.messageReceivedTrigger) - def sendMessageTrigger(self, mess_data, pre_xml_treatments, post_xml_treatments, profile): + def messageSendTrigger(self, client, mess_data, pre_xml_treatments, post_xml_treatments): """Process the XEP-0033 related data to be sent""" + profile = client.profile def treatment(mess_data): if not 'address' in mess_data['extra']: @@ -85,7 +86,7 @@ def discoCallback(entities): if not entities: log.warning(_("XEP-0033 is being used but the server doesn't support it!")) - raise failure.Failure(exceptions.CancelError()) + raise failure.Failure(exceptions.CancelError(u'Cancelled by XEP-0033')) if mess_data["to"] not in entities: expected = _(' or ').join([entity.userhost() for entity in entities]) log.warning(_(u"Stanzas using XEP-0033 should be addressed to %(expected)s, not %(current)s!") % {'expected': expected, 'current': mess_data["to"]}) @@ -98,7 +99,7 @@ # when the prosody plugin is completed, we can immediately return mess_data from here self.sendAndStoreMessage(mess_data, entries, profile) log.debug("XEP-0033 took over") - raise failure.Failure(exceptions.CancelError()) + raise failure.Failure(exceptions.CancelError(u'Cancelled by XEP-0033')) d = self.host.findFeaturesSet([NS_ADDRESS], profile=profile) d.addCallbacks(discoCallback, lambda dummy: discoCallback(None)) return d @@ -117,15 +118,15 @@ Ideas: - fix Prosody plugin to check if target server support the feature - redesign the database to save only one entry to the database - - change the newMessage signal to eventually pass more than one recipient + - change the messageNew signal to eventually pass more than one recipient """ def send(mess_data, skip_send=False): client = self.host.profiles[profile] d = defer.Deferred() if not skip_send: - d.addCallback(self.host._sendMessageToStream, client) - d.addCallback(self.host._storeMessage, client) - d.addCallback(self.host.sendMessageToBridge, client) + d.addCallback(self.host.messageSendToStream, client) + d.addCallback(self.host.messageAddToHistory, client) + d.addCallback(self.host.messageSendToBridge, client) d.addErrback(lambda failure: failure.trap(exceptions.CancelError)) return d.callback(mess_data) @@ -161,7 +162,7 @@ d = defer.Deferred().addCallback(lambda dummy: self.internal_data.pop(timestamp)) defer.DeferredList(defer_list).chainDeferred(d) - def messageReceivedTrigger(self, message, post_treat, profile): + def messageReceivedTrigger(self, client, message, post_treat): """In order to save the addressing information in the history""" def post_treat_addr(data, addresses): data['extra']['addresses'] = "" @@ -174,9 +175,10 @@ try: addresses = message.elements(NS_ADDRESS, 'addresses').next() - post_treat.addCallback(post_treat_addr, addresses.children) except StopIteration: pass # no addresses + else: + post_treat.addCallback(post_treat_addr, addresses.children) return True def getHandler(self, profile):
--- a/src/plugins/plugin_xep_0045.py Fri Jun 24 22:32:58 2016 +0200 +++ b/src/plugins/plugin_xep_0045.py Fri Jun 24 22:41:28 2016 +0200 @@ -23,10 +23,13 @@ log = getLogger(__name__) from twisted.internet import defer from twisted.words.protocols.jabber import jid +from dateutil.tz import tzutc from sat.core import exceptions from sat.memory import memory +import calendar +import time import uuid import copy @@ -50,6 +53,10 @@ NS_MUC = 'http://jabber.org/protocol/muc' AFFILIATIONS = ('owner', 'admin', 'member', 'none', 'outcast') +ROOM_USER_JOINED = 'ROOM_USER_JOINED' +ROOM_USER_LEFT = 'ROOM_USER_LEFT' +OCCUPANT_KEYS = ('nick', 'entity', 'affiliation', 'role') +ENTITY_TYPE_MUC = "MUC" CONFIG_SECTION = u'plugin muc' @@ -73,20 +80,18 @@ def __init__(self, host): log.info(_("Plugin XEP_0045 initialization")) self.host = host - self.clients = {} + self.clients = {} # FIXME: should be moved to profile's client self._sessions = memory.Sessions() - host.bridge.addMethod("joinMUC", ".plugin", in_sign='ssa{ss}s', out_sign='s', method=self._join, async=True) + host.bridge.addMethod("mucJoin", ".plugin", in_sign='ssa{ss}s', out_sign='s', method=self._join, async=True) host.bridge.addMethod("mucNick", ".plugin", in_sign='sss', out_sign='', method=self.mucNick) host.bridge.addMethod("mucLeave", ".plugin", in_sign='ss', out_sign='', method=self.mucLeave, async=True) - host.bridge.addMethod("getRoomsJoined", ".plugin", in_sign='s', out_sign='a(sass)', method=self.getRoomsJoined) + host.bridge.addMethod("getRoomsJoined", ".plugin", in_sign='s', out_sign='a(sa{sa{ss}}ss)', method=self.getRoomsJoined) host.bridge.addMethod("getRoomsSubjects", ".plugin", in_sign='s', out_sign='a(ss)', method=self.getRoomsSubjects) host.bridge.addMethod("getUniqueRoomName", ".plugin", in_sign='ss', out_sign='s', method=self._getUniqueName) host.bridge.addMethod("configureRoom", ".plugin", in_sign='ss', out_sign='s', method=self._configureRoom, async=True) host.bridge.addMethod("getDefaultMUC", ".plugin", in_sign='', out_sign='s', method=self.getDefaultMUC) - host.bridge.addSignal("roomJoined", ".plugin", signature='sasss') # args: room_jid, room_nicks, user_nick, profile + host.bridge.addSignal("roomJoined", ".plugin", signature='sa{sa{ss}}sss') # args: room_jid, occupants, user_nick, subject, profile host.bridge.addSignal("roomLeft", ".plugin", signature='ss') # args: room_jid, profile - host.bridge.addSignal("roomUserJoined", ".plugin", signature='ssa{ss}s') # args: room_jid, user_nick, user_data, profile - host.bridge.addSignal("roomUserLeft", ".plugin", signature='ssa{ss}s') # args: room_jid, user_nick, user_data, profile host.bridge.addSignal("roomUserChangedNick", ".plugin", signature='ssss') # args: room_jid, old_nick, new_nick, profile host.bridge.addSignal("roomNewSubject", ".plugin", signature='sss') # args: room_jid, subject, profile self.__submit_conf_id = host.registerCallback(self._submitConfiguration, with_data=True) @@ -98,6 +103,7 @@ log.info(_("Text commands not available")) host.trigger.add("presence_available", self.presenceTrigger) + host.trigger.add("MessageReceived", self.MessageReceivedTrigger, priority=1000000) def profileConnected(self, profile): def assign_service(service): @@ -105,6 +111,23 @@ client.muc_service = service return self.getMUCService(profile=profile).addCallback(assign_service) + def MessageReceivedTrigger(self, client, message_elt, post_treat): + if message_elt.getAttribute("type") == C.MESS_TYPE_GROUPCHAT: + if message_elt.subject or message_elt.delay: + return False + from_jid = jid.JID(message_elt['from']) + room_jid = from_jid.userhostJID() + if room_jid in self.clients[client.profile].joined_rooms: + room = self.clients[client.profile].joined_rooms[room_jid] + if not room._room_ok: + log.warning(u"Received non delayed message in a room before its initialisation: {}".format(message_elt.toXml())) + room._cache.append(message_elt) + return False + else: + log.warning(u"Received groupchat message for a room which has not been joined, ignoring it: {}".format(message_elt.toXml())) + return False + return True + def checkClient(self, profile): """Check if the profile is connected and has used the MUC feature. @@ -132,29 +155,23 @@ raise UnknownRoom("This room has not been joined") return profile - def __room_joined(self, room, profile): + def _joinCb(self, room, profile): """Called when the user is in the requested room""" - - def _sendBridgeSignal(ignore=None): - self.host.bridge.roomJoined(room.roomJID.userhost(), [user.nick for user in room.roster.values()], room.nick, profile) - - self.clients[profile].joined_rooms[room.roomJID] = room if room.locked: # FIXME: the current behaviour is to create an instant room # and send the signal only when the room is unlocked # a proper configuration management should be done print "room locked !" - self.clients[profile].configure(room.roomJID, {}).addCallbacks(_sendBridgeSignal, lambda x: log.error(_(u'Error while configuring the room'))) - else: - _sendBridgeSignal() + d = self.clients[profile].configure(room.roomJID, {}) + d.addErrback(lambda dummy: log.error(_(u'Error while configuring the room'))) return room - def __err_joining_room(self, failure, room_jid, nick, history_options, password, profile): + def _joinEb(self, failure, room_jid, nick, password, profile): """Called when something is going wrong when joining the room""" if hasattr(failure.value, "condition") and failure.value.condition == 'conflict': # we have a nickname conflict, we try again with "_" suffixed to current nickname nick += '_' - return self.clients[profile].join(room_jid, nick, history_options, password).addCallbacks(self.__room_joined, self.__err_joining_room, callbackKeywords={'profile': profile}, errbackArgs=[room_jid, nick, history_options, password, profile]) + return self.clients[profile].join(room_jid, nick, password).addCallbacks(self._joinCb, self._joinEb, callbackKeywords={'profile': profile}, errbackArgs=[room_jid, nick, password, profile]) mess = D_("Error while joining the room %s" % room_jid.userhost()) try: mess += " with condition '%s'" % failure.value.condition @@ -164,6 +181,11 @@ self.host.bridge.newAlert(mess, D_("Group chat error"), "ERROR", profile) raise failure + @staticmethod + def _getOccupants(room): + """Get occupants of a room in a form suitable for bridge""" + return {u.nick: {k:unicode(getattr(u,k) or '') for k in OCCUPANT_KEYS} for u in room.roster.values()} + def isRoom(self, entity_bare, profile_key): """Tell if a bare entity is a MUC room. @@ -181,7 +203,8 @@ if not self.checkClient(profile): return result for room in self.clients[profile].joined_rooms.values(): - result.append((room.roomJID.userhost(), [user.nick for user in room.roster.values()], room.nick)) + if room._room_ok: + result.append((room.roomJID.userhost(), self._getOccupants(room), room.nick, room.subject)) return result def getRoomNick(self, room_jid, profile_key=C.PROF_KEY_NONE): @@ -301,6 +324,7 @@ def getRoomsSubjects(self, profile_key=C.PROF_KEY_NONE): """Return received subjects of rooms""" + # FIXME: to be removed profile = self.host.memory.getProfileName(profile_key) if not self.checkClient(profile): return [] @@ -355,45 +379,31 @@ """ return self.host.memory.getConfig(CONFIG_SECTION, 'default_muc', default_conf['default_muc']) - def join(self, room_jid, nick, options, profile_key=C.PROF_KEY_NONE): + def join(self, client, room_jid, nick, options): def _errDeferred(exc_obj=Exception, txt='Error while joining room'): d = defer.Deferred() d.errback(exc_obj(txt)) return d - profile = self.host.memory.getProfileName(profile_key) - if not self.checkClient(profile): - return _errDeferred() - if room_jid in self.clients[profile].joined_rooms: - log.warning(_(u'%(profile)s is already in room %(room_jid)s') % {'profile': profile, 'room_jid': room_jid.userhost()}) + if room_jid in self.clients[client.profile].joined_rooms: + log.warning(_(u'%(profile)s is already in room %(room_jid)s') % {'profile': client.profile, 'room_jid': room_jid.userhost()}) return _errDeferred(AlreadyJoinedRoom, D_(u"The room has already been joined")) - log.info(_(u"[%(profile)s] is joining room %(room)s with nick %(nick)s") % {'profile': profile, 'room': room_jid.userhost(), 'nick': nick}) + log.info(_(u"[%(profile)s] is joining room %(room)s with nick %(nick)s") % {'profile': client.profile, 'room': room_jid.userhost(), 'nick': nick}) - if "history" in options: - history_limit = int(options["history"]) - else: - history_limit = int(self.host.memory.getParamA(C.HISTORY_LIMIT, 'General', profile_key=profile)) - # http://xmpp.org/extensions/xep-0045.html#enter-managehistory - history_options = muc.HistoryOptions(maxStanzas=history_limit) password = options["password"] if "password" in options else None - return self.clients[profile].join(room_jid, nick, history_options, password).addCallbacks(self.__room_joined, self.__err_joining_room, callbackKeywords={'profile': profile}, errbackArgs=[room_jid, nick, history_options, password, profile]) - # FIXME: how to set the cancel method on the Deferred created by wokkel? - # This happens when the room is not reachable, e.g. no internet connection: - # > /usr/local/lib/python2.7/dist-packages/twisted/internet/defer.py(480)_startRunCallbacks() - # -> raise AlreadyCalledError(extra) + return self.clients[client.profile].join(room_jid, nick, password).addCallbacks(self._joinCb, self._joinEb, callbackKeywords={'profile': client.profile}, errbackArgs=[room_jid, nick, password, client.profile]) def _join(self, room_jid_s, nick, options=None, profile_key=C.PROF_KEY_NONE): - """join method used by bridge: use the join method, but doesn't return any deferred + """join method used by bridge + @return: unicode (the room bare) """ + client = self.host.getClient(profile_key) if options is None: options = {} - profile = self.host.memory.getProfileName(profile_key) - if not self.checkClient(profile): - return if room_jid_s: - muc_service = self.host.getClient(profile).muc_service + muc_service = self.host.getClient(client.profile).muc_service try: room_jid = jid.JID(room_jid_s) except (RuntimeError, jid.InvalidFormat, AttributeError): @@ -401,9 +411,9 @@ if not room_jid.user: room_jid.user, room_jid.host = room_jid.host, muc_service else: - room_jid = self.getUniqueName(profile_key=profile_key) + room_jid = self.getUniqueName(profile_key=client.profile) # TODO: error management + signal in bridge - d = self.join(room_jid, nick, options, profile) + d = self.join(client, room_jid, nick, options) return d.addCallback(lambda room: room.roomJID.userhost()) def nick(self, room_jid, nick, profile_key): @@ -477,7 +487,7 @@ # Text commands # - def cmd_nick(self, mess_data, profile): + def cmd_nick(self, client, mess_data): """change nickname @command (group): new_nick @@ -486,11 +496,11 @@ nick = mess_data["unparsed"].strip() if nick: room = mess_data["to"] - self.nick(room, nick, profile) + self.nick(room, nick, client.profile) return False - def cmd_join(self, mess_data, profile): + def cmd_join(self, client, mess_data): """join a new room @command (all): JID @@ -498,13 +508,13 @@ """ if mess_data["unparsed"].strip(): room_jid = self.host.plugins[C.TEXT_CMDS].getRoomJID(mess_data["unparsed"].strip(), mess_data["to"].host) - nick = (self.getRoomNick(room_jid, profile) or - self.host.getClient(profile).jid.user) - self.join(room_jid, nick, {}, profile) + nick = (self.getRoomNick(room_jid, client.profile) or + self.host.getClient(client.profile).jid.user) + self.join(client, room_jid, nick, {}) return False - def cmd_leave(self, mess_data, profile): + def cmd_leave(self, client, mess_data): """quit a room @command (group): [ROOM_JID] @@ -515,19 +525,19 @@ else: room = mess_data["to"] - self.leave(room, profile) + self.leave(room, client.profile) return False - def cmd_part(self, mess_data, profile): + def cmd_part(self, client, mess_data): """just a synonym of /leave @command (group): [ROOM_JID] - ROOM_JID: jid of the room to live (current room if not specified) """ - return self.cmd_leave(mess_data, profile) + return self.cmd_leave(client, mess_data) - def cmd_kick(self, mess_data, profile): + def cmd_kick(self, client, mess_data): """kick a room member @command (group): ROOM_NICK @@ -536,24 +546,24 @@ options = mess_data["unparsed"].strip().split() try: nick = options[0] - assert(self.isNickInRoom(mess_data["to"], nick, profile)) + assert(self.isNickInRoom(mess_data["to"], nick, client.profile)) except (IndexError, AssertionError): feedback = _(u"You must provide a member's nick to kick.") - self.host.plugins[C.TEXT_CMDS].feedBack(feedback, mess_data, profile) + self.host.plugins[C.TEXT_CMDS].feedBack(client, feedback, mess_data) return False - d = self.kick(nick, mess_data["to"], {} if len(options) == 1 else {'reason': options[1]}, profile) + d = self.kick(nick, mess_data["to"], {} if len(options) == 1 else {'reason': options[1]}, client.profile) def cb(dummy): feedback_msg = _(u'You have kicked {}').format(nick) if len(options) > 1: feedback_msg += _(u' for the following reason: {}').format(options[1]) - self.host.plugins[C.TEXT_CMDS].feedBack(feedback_msg, mess_data, profile) + self.host.plugins[C.TEXT_CMDS].feedBack(client, feedback_msg, mess_data) return True d.addCallback(cb) return d - def cmd_ban(self, mess_data, profile): + def cmd_ban(self, client, mess_data): """ban an entity from the room @command (group): (JID) [reason] @@ -568,21 +578,21 @@ assert(entity_jid.host) except (RuntimeError, jid.InvalidFormat, AttributeError, IndexError, AssertionError): feedback = _(u"You must provide a valid JID to ban, like in '/ban contact@example.net'") - self.host.plugins[C.TEXT_CMDS].feedBack(feedback, mess_data, profile) + self.host.plugins[C.TEXT_CMDS].feedBack(client, feedback, mess_data) return False - d = self.ban(entity_jid, mess_data["to"], {} if len(options) == 1 else {'reason': options[1]}, profile) + d = self.ban(entity_jid, mess_data["to"], {} if len(options) == 1 else {'reason': options[1]}, client.profile) def cb(dummy): feedback_msg = _(u'You have banned {}').format(entity_jid) if len(options) > 1: feedback_msg += _(u' for the following reason: {}').format(options[1]) - self.host.plugins[C.TEXT_CMDS].feedBack(feedback_msg, mess_data, profile) + self.host.plugins[C.TEXT_CMDS].feedBack(client, feedback_msg, mess_data) return True d.addCallback(cb) return d - def cmd_affiliate(self, mess_data, profile): + def cmd_affiliate(self, client, mess_data): """affiliate an entity to the room @command (group): (JID) [owner|admin|member|none|outcast] @@ -601,25 +611,25 @@ assert(entity_jid.host) except (RuntimeError, jid.InvalidFormat, AttributeError, IndexError, AssertionError): feedback = _(u"You must provide a valid JID to affiliate, like in '/affiliate contact@example.net member'") - self.host.plugins[C.TEXT_CMDS].feedBack(feedback, mess_data, profile) + self.host.plugins[C.TEXT_CMDS].feedBack(client, feedback, mess_data) return False affiliation = options[1] if len(options) > 1 else 'none' if affiliation not in AFFILIATIONS: feedback = _(u"You must provide a valid affiliation: %s") % ' '.join(AFFILIATIONS) - self.host.plugins[C.TEXT_CMDS].feedBack(feedback, mess_data, profile) + self.host.plugins[C.TEXT_CMDS].feedBack(client, feedback, mess_data) return False - d = self.affiliate(entity_jid, mess_data["to"], {'affiliation': affiliation}, profile) + d = self.affiliate(entity_jid, mess_data["to"], {'affiliation': affiliation}, client.profile) def cb(dummy): feedback_msg = _(u'New affiliation for %(entity)s: %(affiliation)s').format(entity=entity_jid, affiliation=affiliation) - self.host.plugins[C.TEXT_CMDS].feedBack(feedback_msg, mess_data, profile) + self.host.plugins[C.TEXT_CMDS].feedBack(client, feedback_msg, mess_data) return True d.addCallback(cb) return d - def cmd_title(self, mess_data, profile): + def cmd_title(self, client, mess_data): """change room's subject @command (group): title @@ -629,28 +639,28 @@ if subject: room = mess_data["to"] - self.subject(room, subject, profile) + self.subject(room, subject, client.profile) return False - def cmd_topic(self, mess_data, profile): + def cmd_topic(self, client, mess_data): """just a synonym of /title @command (group): title - title: new room subject """ - return self.cmd_title(mess_data, profile) + return self.cmd_title(client, mess_data) - def _whois(self, whois_msg, mess_data, target_jid, profile): + def _whois(self, client, whois_msg, mess_data, target_jid): """ Add MUC user information to whois """ if mess_data['type'] != "groupchat": return - if target_jid.userhostJID() not in self.clients[profile].joined_rooms: + if target_jid.userhostJID() not in self.clients[client.profile].joined_rooms: log.warning(_("This room has not been joined")) return if not target_jid.resource: return - user = self.clients[profile].joined_rooms[target_jid.userhostJID()].getUser(target_jid.resource) + user = self.clients[client.profile].joined_rooms[target_jid.userhostJID()].getUser(target_jid.resource) whois_msg.append(_("Nickname: %s") % user.nick) if user.entity: whois_msg.append(_("Entity: %s") % user.entity) @@ -681,16 +691,40 @@ self.host = plugin_parent.host muc.MUCClient.__init__(self) self.rec_subjects = {} - self.__changing_nicks = set() # used to keep trace of who is changing nick, - # and to discard userJoinedRoom signal in this case + self._changing_nicks = set() # used to keep trace of who is changing nick, + # and to discard userJoinedRoom signal in this case print "init SatMUCClient OK" @property def joined_rooms(self): return self._rooms - def subject(self, room, subject): - return muc.MUCClientProtocol.subject(self, room, subject) + def _addRoom(self, room): + super(SatMUCClient, self)._addRoom(room) + room._roster_ok = False + room._room_ok = None # False when roster, history and subject are available + # True when new messages are saved to database + room._history_d = defer.Deferred() # use to send bridge signal once backlog are written in history + room._history_d.callback(None) + room._cache = [] + + def _gotLastDbHistory(self, mess_data_list, room_jid, nick, password): + if mess_data_list: + timestamp = mess_data_list[0][1] + # we use seconds since last message to get backlog without duplicates + # and we remove 1 second to avoid getting the last message again + seconds = int(time.time() - timestamp) - 1 + else: + seconds = None + d = super(SatMUCClient, self).join(room_jid, nick, muc.HistoryOptions(seconds=seconds), password) + return d + + def join(self, room_jid, nick, password=None): + d = self.host.memory.historyGet(self.parent.jid.userhostJID(), room_jid, 1, True, profile=self.parent.profile) + d.addCallback(self._gotLastDbHistory, room_jid, nick, password) + return d + + ## presence/roster ## def availableReceived(self, presence): """ @@ -698,7 +732,6 @@ """ # XXX: we override MUCClient.availableReceived to fix bugs # (affiliation and role are not set) - # FIXME: propose a patch upstream room, user = self._getRoomUser(presence) @@ -720,9 +753,9 @@ else: room.addUser(user) self.userJoinedRoom(room, user) + def unavailableReceived(self, presence): # XXX: we override this method to manage nickname change - # TODO: feed this back to Wokkel """ Unavailable presence was received. @@ -737,22 +770,49 @@ room.removeUser(user) if muc.STATUS_CODE.NEW_NICK in presence.mucStatuses: - self.__changing_nicks.add(presence.nick) + self._changing_nicks.add(presence.nick) self.userChangedNick(room, user, presence.nick) else: - self.__changing_nicks.discard(presence.nick) + self._changing_nicks.discard(presence.nick) self.userLeftRoom(room, user) def userJoinedRoom(self, room, user): - self.host.memory.updateEntityData(room.roomJID, "type", "chatroom", profile_key=self.parent.profile) - if user.nick in self.__changing_nicks: - self.__changing_nicks.remove(user.nick) - else: - log.debug(_(u"user %(nick)s has joined room (%(room_id)s)") % {'nick': user.nick, 'room_id': room.occupantJID.userhost()}) - if not self.host.trigger.point("MUC user joined", room, user, self.parent.profile): - return - user_data = {'entity': user.entity.full() if user.entity else '', 'affiliation': user.affiliation, 'role': user.role} - self.host.bridge.roomUserJoined(room.roomJID.userhost(), user.nick, user_data, self.parent.profile) + if user.nick == room.nick: + # we have received our own nick, this mean that the full room roster was received + room._roster_ok = True + log.debug(u"room {room} joined with nick {nick}".format(room=room.occupantJID.userhost(), nick=user.nick)) + # We set type so we don't have use a deferred with disco to check entity type + self.host.memory.updateEntityData(room.roomJID, C.ENTITY_TYPE, ENTITY_TYPE_MUC, profile_key=self.parent.profile) + + elif room._roster_ok: + try: + self._changing_nicks.remove(user.nick) + except KeyError: + # this is a new user + log.debug(_(u"user {nick} has joined room {room_id}").format(nick=user.nick, room_id=room.occupantJID.userhost())) + if not self.host.trigger.point("MUC user joined", room, user, self.parent.profile): + return + + extra = {'info_type': ROOM_USER_JOINED, + 'user_affiliation': user.affiliation, + 'user_role': user.role, + 'user_nick': user.nick + } + if user.entity is not None: + extra['user_entity'] = user.entity.full() + mess_data = { # dict is similar to the one used in client.onMessage + "from": room.roomJID, + "to": self.parent.jid, + "uid": unicode(uuid.uuid4()), + "message": {'': D_(u"=> {} has joined the room").format(user.nick)}, + "subject": {}, + "type": C.MESS_TYPE_INFO, + "extra": extra, + "timestamp": time.time(), + } + self.host.messageAddToHistory(mess_data, self.parent) + self.host.messageSendToBridge(mess_data, self.parent) + def userLeftRoom(self, room, user): if not self.host.trigger.point("MUC user left", room, user, self.parent.profile): @@ -760,14 +820,31 @@ if user.nick == room.nick: # we left the room room_jid_s = room.roomJID.userhost() - log.info(_(u"Room [%(room)s] left (%(profile)s))") % {"room": room_jid_s, - "profile": self.parent.profile}) + log.info(_(u"Room ({room}) left ({profile})").format( + room = room_jid_s, profile = self.parent.profile)) self.host.memory.delEntityCache(room.roomJID, profile_key=self.parent.profile) self.host.bridge.roomLeft(room.roomJID.userhost(), self.parent.profile) else: - log.debug(_(u"user %(nick)s left room (%(room_id)s)") % {'nick': user.nick, 'room_id': room.occupantJID.userhost()}) - user_data = {'entity': user.entity.full() if user.entity else '', 'affiliation': user.affiliation, 'role': user.role} - self.host.bridge.roomUserLeft(room.roomJID.userhost(), user.nick, user_data, self.parent.profile) + log.debug(_(u"user {nick} left room {room_id}").format(nick=user.nick, room_id=room.occupantJID.userhost())) + extra = {'info_type': ROOM_USER_LEFT, + 'user_affiliation': user.affiliation, + 'user_role': user.role, + 'user_nick': user.nick + } + if user.entity is not None: + extra['user_entity'] = user.entity.full() + mess_data = { # dict is similar to the one used in client.onMessage + "from": room.roomJID, + "to": self.parent.jid, + "uid": unicode(uuid.uuid4()), + "message": {'': D_(u"<= {} has left the room").format(user.nick)}, + "subject": {}, + "type": C.MESS_TYPE_INFO, + "extra": extra, + "timestamp": time.time(), + } + self.host.messageAddToHistory(mess_data, self.parent) + self.host.messageSendToBridge(mess_data, self.parent) def userChangedNick(self, room, user, new_nick): self.host.bridge.roomUserChangedNick(room.roomJID.userhost(), user.nick, new_nick, self.parent.profile) @@ -775,19 +852,111 @@ def userUpdatedStatus(self, room, user, show, status): self.host.bridge.presenceUpdate(room.roomJID.userhost() + '/' + user.nick, show or '', 0, {C.PRESENCE_STATUSES_DEFAULT: status or ''}, self.parent.profile) + ## messages ## + def receivedGroupChat(self, room, user, body): log.debug(u'receivedGroupChat: room=%s user=%s body=%s' % (room.roomJID.full(), user, body)) + def _addToHistory(self, dummy, user, message): + # we check if message is not in history + # and raise ConflictError else + stamp = message.delay.stamp.astimezone(tzutc()).timetuple() + timestamp = float(calendar.timegm(stamp)) + data = { # dict is similar to the one used in client.onMessage + "from": message.sender, + "to": message.recipient, + "uid": unicode(uuid.uuid4()), + "type": C.MESS_TYPE_GROUPCHAT, + "extra": {}, + "timestamp": timestamp, + "received_timestamp": unicode(time.time()), + } + # FIXME: message and subject don't handle xml:lang + data['message'] = {'': message.body} if message.body is not None else {} + data['subject'] = {'': message.subject} if message.subject is not None else {} + + if data['message'] or data['subject']: + return self.host.memory.addToHistory(self.parent, data) + else: + return defer.succeed(None) + + def _addToHistoryEb(self, failure): + failure.trap(exceptions.CancelError) + def receivedHistory(self, room, user, message): - # http://xmpp.org/extensions/xep-0045.html#enter-history - # log.debug(u'receivedHistory: room=%s user=%s body=%s' % (room.roomJID.full(), user, message)) - pass + """Called when history (backlog) message are received + + we check if message is not already in our history + and add it if needed + @param room(muc.Room): room instance + @param user(muc.User, None): the user that sent the message + None if the message come from the room + @param message(muc.GroupChat): the parsed message + """ + room._history_d.addCallback(self._addToHistory, user, message) + room._history_d.addErrback(self._addToHistoryEb) + + ## subject ## + + def groupChatReceived(self, message): + """ + A group chat message has been received from a MUC room. + + There are a few event methods that may get called here. + L{receivedGroupChat}, L{receivedSubject} or L{receivedHistory}. + """ + # We override this method to fix subject handling + # FIXME: remove this merge fixed upstream + room, user = self._getRoomUser(message) + + if room is None: + return + + if message.subject is not None: + self.receivedSubject(room, user, message.subject) + elif message.delay is None: + self.receivedGroupChat(room, user, message) + else: + self.receivedHistory(room, user, message) + + def subject(self, room, subject): + return muc.MUCClientProtocol.subject(self, room, subject) + + def _historyCb(self, dummy, room): + self.host.bridge.roomJoined( + room.roomJID.userhost(), + XEP_0045._getOccupants(room), + room.nick, + room.subject, + self.parent.profile) + del room._history_d + cache = room._cache + del room._cache + room._room_ok = True + for elem in cache: + self.parent.xmlstream.dispatch(elem) + + + def _historyEb(self, failure_, room): + log.error(u"Error while managing history: {}".format(failure_)) + self._historyCb(None, room) def receivedSubject(self, room, user, subject): - # http://xmpp.org/extensions/xep-0045.html#enter-subject - log.debug(_(u"New subject for room (%(room_id)s): %(subject)s") % {'room_id': room.roomJID.full(), 'subject': subject}) + # when subject is received, we know that we have whole roster and history + # cf. http://xmpp.org/extensions/xep-0045.html#enter-subject + room.subject = subject # FIXME: subject doesn't handle xml:lang self.rec_subjects[room.roomJID.userhost()] = (room.roomJID.userhost(), subject) - self.host.bridge.roomNewSubject(room.roomJID.userhost(), subject, self.parent.profile) + if room._room_ok is None: + # this is the first subject we receive + # that mean that we have received everything we need + room._room_ok = False + room._history_d.addCallbacks(self._historyCb, self._historyEb, [room], errbackArgs=[room]) + else: + # the subject has been changed + log.debug(_(u"New subject for room ({room_id}): {subject}").format(room_id = room.roomJID.full(), subject = subject)) + self.host.bridge.roomNewSubject(room.roomJID.userhost(), subject, self.parent.profile) + + ## disco ## def getDiscoInfo(self, requestor, target, nodeIdentifier=''): return [disco.DiscoFeature(NS_MUC)]
--- a/src/plugins/plugin_xep_0048.py Fri Jun 24 22:32:58 2016 +0200 +++ b/src/plugins/plugin_xep_0048.py Fri Jun 24 22:41:28 2016 +0200 @@ -88,7 +88,7 @@ for (room_jid, data) in bookmarks[XEP_0048.MUC_TYPE].items(): if data.get('autojoin', 'false') == 'true': nick = data.get('nick', client.jid.user) - self.host.plugins['XEP-0045'].join(room_jid, nick, {}, profile_key=client.profile) + self.host.plugins['XEP-0045'].join(client, room_jid, nick, {}) @defer.inlineCallbacks def _getServerBookmarks(self, storage_type, profile): @@ -219,9 +219,10 @@ log.warning(_("No room jid selected")) return {} - d = self.host.plugins['XEP-0045'].join(room_jid, nick, {}, profile_key=profile) + client = self.host.getClient(profile) + d = self.host.plugins['XEP-0045'].join(client, room_jid, nick, {}) def join_eb(failure): - log.warning(u"Error while trying to join room: %s" % failure) + log.warning(u"Error while trying to join room: {}".format(failure)) # FIXME: failure are badly managed in plugin XEP-0045. Plugin XEP-0045 need to be fixed before managing errors correctly here return {} d.addCallbacks(lambda dummy: {}, join_eb) @@ -417,33 +418,32 @@ location = jid.JID(location) return self.addBookmark(type_, location, data, storage_type, profile_key) - def cmd_bookmark(self, mess_data, profile): + def cmd_bookmark(self, client, mess_data): """(Un)bookmark a MUC room @command (group): [autojoin | remove] - autojoin: join room automatically on connection - remove: remove bookmark(s) for this room """ - client = self.host.getClient(profile) txt_cmd = self.host.plugins[C.TEXT_CMDS] options = mess_data["unparsed"].strip().split() if options and options[0] not in ('autojoin', 'remove'): - txt_cmd.feedBack(_("Bad arguments"), mess_data, profile) + txt_cmd.feedBack(client, _("Bad arguments"), mess_data) return False room_jid = mess_data["to"].userhostJID() if "remove" in options: - self.removeBookmark(XEP_0048.MUC_TYPE, room_jid, profile_key = profile) - txt_cmd.feedBack(_("All [%s] bookmarks are being removed") % room_jid.full(), mess_data, profile) + self.removeBookmark(XEP_0048.MUC_TYPE, room_jid, profile_key = client.profile) + txt_cmd.feedBack(client, _("All [%s] bookmarks are being removed") % room_jid.full(), mess_data) return False data = { "name": room_jid.user, "nick": client.jid.user, "autojoin": "true" if "autojoin" in options else "false", } - self.addBookmark(XEP_0048.MUC_TYPE, room_jid, data, profile_key=profile) - txt_cmd.feedBack(_("Bookmark added"), mess_data, profile) + self.addBookmark(XEP_0048.MUC_TYPE, room_jid, data, profile_key=client.profile) + txt_cmd.feedBack(client, _("Bookmark added"), mess_data) return False
--- a/src/plugins/plugin_xep_0071.py Fri Jun 24 22:32:58 2016 +0200 +++ b/src/plugins/plugin_xep_0071.py Fri Jun 24 22:41:28 2016 +0200 @@ -18,10 +18,12 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. from sat.core.i18n import _ +from sat.core.constants import Const as C from sat.core import exceptions from sat.core.log import getLogger log = getLogger(__name__) +from twisted.internet import defer from wokkel import disco, iwokkel from zope.interface import implements # from lxml import etree @@ -75,68 +77,108 @@ def __init__(self, host): log.info(_("XHTML-IM plugin initialization")) self.host = host - self.synt_plg = self.host.plugins["TEXT-SYNTAXES"] - self.synt_plg.addSyntax(self.SYNTAX_XHTML_IM, lambda xhtml: xhtml, self.XHTML2XHTML_IM, [self.synt_plg.OPT_HIDDEN]) + self._s = self.host.plugins["TEXT-SYNTAXES"] + self._s.addSyntax(self.SYNTAX_XHTML_IM, lambda xhtml: xhtml, self.XHTML2XHTML_IM, [self._s.OPT_HIDDEN]) host.trigger.add("MessageReceived", self.messageReceivedTrigger) - host.trigger.add("sendMessage", self.sendMessageTrigger) + host.trigger.add("messageSend", self.messageSendTrigger) def getHandler(self, profile): return XEP_0071_handler(self) - def _messagePostTreat(self, data, body_elt): - """ Callback which manage the post treatment of the message in case of XHTML-IM found + def _messagePostTreat(self, data, body_elts): + """Callback which manage the post treatment of the message in case of XHTML-IM found @param data: data send by MessageReceived trigger through post_treat deferred - @param xhtml_im: XHTML-IM body element found + @param body_elts: XHTML-IM body elements found @return: the data with the extra parameter updated """ - #TODO: check if text only body is empty, then try to convert XHTML-IM to pure text and show a warning message - def converted(xhtml): - data['extra']['xhtml'] = xhtml - return data - d = self.synt_plg.convert(body_elt.toXml(), self.SYNTAX_XHTML_IM, safe=True) - d.addCallback(converted) - return d + # TODO: check if text only body is empty, then try to convert XHTML-IM to pure text and show a warning message + def converted(xhtml, lang): + if lang: + data['extra']['xhtml_{}'.format(lang)] = xhtml + else: + data['extra']['xhtml'] = xhtml - def _sendMessageAddRich(self, mess_data, profile): + defers = [] + for body_elt in body_elts: + lang = body_elt.getAttribute((C.NS_XML, 'lang'), '') + d = self._s.convert(body_elt.toXml(), self.SYNTAX_XHTML_IM, safe=True) + d.addCallback(converted, lang) + defers.append(d) + + d_list = defer.DeferredList(defers) + d_list.addCallback(lambda dummy: data) + return d_list + + def _messageSendAddRich(self, data, client): """ Construct XHTML-IM node and add it XML element - @param mess_data: message data as sended by sendMessage callback + + @param data: message data as sended by messageSend callback """ - def syntax_converted(xhtml_im): - message_elt = mess_data['xml'] - html_elt = message_elt.addElement('html', NS_XHTML_IM) - body_elt = html_elt.addElement('body', NS_XHTML) + # at this point, either ['extra']['rich'] or ['extra']['xhtml'] exists + # but both can't exist at the same time + message_elt = data['xml'] + html_elt = message_elt.addElement((NS_XHTML_IM, 'html')) + + def syntax_converted(xhtml_im, lang): + body_elt = html_elt.addElement((NS_XHTML, 'body')) + if lang: + body_elt[(C.NS_XML, 'lang')] = lang + data['extra']['xhtml_{}'.format(lang)] = xhtml_im + else: + data['extra']['xhtml'] = xhtml_im body_elt.addRawXml(xhtml_im) - mess_data['extra']['xhtml'] = xhtml_im - return mess_data - syntax = self.synt_plg.getCurrentSyntax(profile) - rich = mess_data['extra'].get('rich', '') - xhtml = mess_data['extra'].get('xhtml', '') - if rich: - d = self.synt_plg.convert(rich, syntax, self.SYNTAX_XHTML_IM) - if xhtml: - raise exceptions.DataError(_("Can't have xhtml and rich content at the same time")) - d.addCallback(syntax_converted) - return d + syntax = self._s.getCurrentSyntax(client.profile) + defers = [] + try: + rich = data['extra']['rich'] + except KeyError: + # we have directly XHTML + for lang, xhtml in data['extra']['xhtml'].iteritems(): + d = self._s.convert(xhtml, self._s.SYNTAX_XHTML, self.SYNTAX_XHTML_IM) + d.addCallback(syntax_converted, lang) + defers.append(d) + else: + # we have rich syntax to convert + for lang, rich_data in rich.iteritems(): + d = self._s.convert(rich_data, syntax, self.SYNTAX_XHTML_IM) + d.addCallback(syntax_converted, lang) + defers.append(d) + d_list = defer.DeferredList(defers) + d_list.addCallback(lambda dummy: data) + return d_list - def messageReceivedTrigger(self, message, post_treat, profile): + def messageReceivedTrigger(self, client, message, post_treat): """ Check presence of XHTML-IM in message """ try: html_elt = message.elements(NS_XHTML_IM, 'html').next() - body_elt = html_elt.elements(NS_XHTML, 'body').next() - # OK, we have found rich text - post_treat.addCallback(self._messagePostTreat, body_elt) except StopIteration: # No XHTML-IM pass + else: + body_elts = html_elt.elements(NS_XHTML, 'body') + post_treat.addCallback(self._messagePostTreat, body_elts) return True - def sendMessageTrigger(self, mess_data, pre_xml_treatments, post_xml_treatments, profile): + def messageSendTrigger(self, client, data, pre_xml_treatments, post_xml_treatments): """ Check presence of rich text in extra """ - if 'rich' in mess_data['extra'] or 'xhtml' in mess_data['extra']: - post_xml_treatments.addCallback(self._sendMessageAddRich, profile) + rich = {} + xhtml = {} + for key, value in data['extra'].iteritems(): + if key.startswith('rich'): + rich[key[5:]] = value + elif key.startswith('xhtml'): + xhtml[key[6:]] = value + if rich and xhtml: + raise exceptions.DataError(_(u"Can't have XHTML and rich content at the same time")) + if rich or xhtml: + if rich: + data['rich'] = rich + else: + data['xhtml'] = xhtml + post_xml_treatments.addCallback(self._messageSendAddRich, client) return True def _purgeStyle(self, styles_raw):
--- a/src/plugins/plugin_xep_0085.py Fri Jun 24 22:32:58 2016 +0200 +++ b/src/plugins/plugin_xep_0085.py Fri Jun 24 22:41:28 2016 +0200 @@ -103,7 +103,7 @@ # triggers from core host.trigger.add("MessageReceived", self.messageReceivedTrigger) - host.trigger.add("sendMessage", self.sendMessageTrigger) + host.trigger.add("messageSend", self.messageSendTrigger) host.trigger.add("paramUpdateTrigger", self.paramUpdateTrigger) # args: to_s (jid as string), profile @@ -156,11 +156,12 @@ return False return True - def messageReceivedTrigger(self, message, post_treat, profile): + def messageReceivedTrigger(self, client, message, post_treat): """ Update the entity cache when we receive a message with body. Check for a chat state in the message and signal frontends. """ + profile = client.profile if not self.host.memory.getParamA(PARAM_NAME, PARAM_KEY, profile_key=profile): return True @@ -197,11 +198,12 @@ break return True - def sendMessageTrigger(self, mess_data, pre_xml_treatments, post_xml_treatments, profile): + def messageSendTrigger(self, client, mess_data, pre_xml_treatments, post_xml_treatments): """ Eventually add the chat state to the message and initiate the state machine when sending an "active" state. """ + profile = client.profile def treatment(mess_data): message = mess_data['xml'] to_jid = JID(message.getAttribute("to")) @@ -362,12 +364,15 @@ # send a new message without body log.debug(u"sending state '{state}' to {jid}".format(state=state, jid=self.to_jid.full())) client = self.host.getClient(self.profile) - mess_data = {'message': None, - 'type': self.mess_type, - 'from': client.jid, - 'to': self.to_jid, - 'subject': None - } + mess_data = { + 'from': client.jid, + 'to': self.to_jid, + 'uid': '', + 'message': {}, + 'type': self.mess_type, + 'subject': {}, + 'extra': {}, + } self.host.generateMessageXML(mess_data) mess_data['xml'].addElement(state, NS_CHAT_STATES) client.xmlstream.send(mess_data['xml'])
--- a/src/plugins/plugin_xep_0092.py Fri Jun 24 22:32:58 2016 +0200 +++ b/src/plugins/plugin_xep_0092.py Fri Jun 24 22:41:28 2016 +0200 @@ -99,7 +99,7 @@ return tuple(ret) - def _whois(self, whois_msg, mess_data, target_jid, profile): + def _whois(self, client, whois_msg, mess_data, target_jid): """ Add software/OS information to whois """ def versionCb(version_data): name, version, os = version_data @@ -116,7 +116,7 @@ else: whois_msg.append(_("Client software version request timeout")) - d = self.getVersion(target_jid, profile) + d = self.getVersion(target_jid, client.profile) d.addCallbacks(versionCb, versionEb) return d
--- a/src/plugins/plugin_xep_0203.py Fri Jun 24 22:32:58 2016 +0200 +++ b/src/plugins/plugin_xep_0203.py Fri Jun 24 22:41:28 2016 +0200 @@ -22,7 +22,6 @@ from sat.core.log import getLogger log = getLogger(__name__) -from calendar import timegm from wokkel import disco, iwokkel, delay try: from twisted.words.protocols.xmlstream import XMPPHandler @@ -49,8 +48,6 @@ def __init__(self, host): log.info(_("Delayed Delivery plugin initialization")) self.host = host - host.trigger.add("MessageReceived", self.messageReceivedTrigger) - def getHandler(self, profile): return XEP_0203_handler(self, profile) @@ -71,30 +68,6 @@ parent.addChild(elt) return elt - def messagePostTreat(self, data, timestamp): - """Set the timestamp of a received message. - - @param data (dict): data send by MessageReceived trigger through post_treat deferred - @param timestamp (int): original timestamp of a delayed message - @return: dict - """ - data['extra']['timestamp'] = unicode(timestamp) - return data - - def messageReceivedTrigger(self, message, post_treat, profile): - """Process a delay element from incoming message. - - @param message (domish.Element): message element - @param post_treat (Deferred): deferred instance to add post treatments - """ - try: - delay_ = delay.Delay.fromElement([elm for elm in message.elements() if elm.name == 'delay'][0]) - except IndexError: - return True - else: - timestamp = timegm(delay_.stamp.utctimetuple()) - post_treat.addCallback(self.messagePostTreat, timestamp) - return True class XEP_0203_handler(XMPPHandler): implements(iwokkel.IDisco)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/plugins/plugin_xep_0245.py Fri Jun 24 22:41:28 2016 +0200 @@ -0,0 +1,101 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin for managing xep-245 +# Copyright (C) 2009-2016 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from sat.core.i18n import _ +from sat.core.constants import Const as C +from sat.core.log import getLogger +log = getLogger(__name__) + + +PLUGIN_INFO = { + "name": "XEP-0245 Plugin", + "import_name": "XEP-0245", + "type": "XEP", + "protocols": ["XEP-0245"], + "recommendations": [C.TEXT_CMDS], + "main": "XEP_0245", + "handler": "no", + "description": _("""/me syntax handling""") +} + + +class XEP_0245(object): + + def __init__(self, host): + log.info(_("Plugin XEP_245 initialization")) + self.host = host + try: + self.host.plugins[C.TEXT_CMDS].registerTextCommands(self) + except KeyError: + pass + host.trigger.add("messageSend", self.MessageSendTrigger) + host.trigger.add("MessageReceived", self.MessageReceivedTrigger) + + def handleMe(self, mess_data, client): + """Check if messages starts with "/me " and change them if it is the case + + if several messages (different languages) are presents, they all need to start with "/me " + if it is for a group chat, resource is used as nick, else roster.getNick is used + """ + # TODO: XHTML-IM /me are not handled + for lang, mess in mess_data['message'].iteritems(): + if not mess.startswith('/me '): + # if not all messages start with "/me", no need to continue + return mess_data + try: + nick = mess_data['nick'] + except KeyError: + if mess_data['type'] == C.MESS_TYPE_GROUPCHAT: + nick = mess_data['nick'] = mess_data['from'].resource + else: + from_jid = mess_data['from'] + try: + ent_type = self.host.memory.getEntityDatum(from_jid.userhostJID(), C.ENTITY_TYPE, client.profile) + except KeyError: + ent_type = None + if ent_type == 'MUC': + nick = mess_data['nick'] = from_jid.resource + else: + nick = mess_data['nick'] = client.roster.getNick(from_jid) + mess_data.setdefault('me_update', {})[lang] = u"* {}{}".format(nick, mess[3:]) + + if 'me_update' in mess_data: + mess_data['message'].update(mess_data.pop('me_update')) + mess_data["type"] = C.MESS_TYPE_INFO + mess_data["extra"][C.MESS_EXTRA_INFO] = "me" + return mess_data + + def MessageSendTrigger(self, client, data, pre_xml_treatments, post_xml_treatments): + post_xml_treatments.addCallback(self.handleMe, client) + return True + + def MessageReceivedTrigger(self, client, message_elt, post_treat): + """ Check if source is linked and repeat message, else do nothing """ + post_treat.addCallback(self.handleMe, client) + return True + + def cmd_me(self, client, mess_data): + """display a message at third person + + @command (all): message + - message: message to show at third person + e.g.: "/me clenches his fist" will give "[YOUR_NICK] clenches his fist" + """ + # We just ignore the command as the match is done on receiption by clients + return True
--- a/src/plugins/plugin_xep_0249.py Fri Jun 24 22:32:58 2016 +0200 +++ b/src/plugins/plugin_xep_0249.py Fri Jun 24 22:41:28 2016 +0200 @@ -125,13 +125,9 @@ @param room (jid.JID): JID of the room """ - profile = self.host.memory.getProfileName(profile_key) - if not profile: - log.error(_("Profile doesn't exists !")) - return - log.info(_(u'Invitation accepted for room %(room)s [%(profile)s]') % {'room': room_jid.userhost(), 'profile': profile}) - _jid, xmlstream = self.host.getJidNStream(profile) - d = self.host.plugins["XEP-0045"].join(room_jid, _jid.user, {}, profile) + client = self.host.getClient(profile_key) + log.info(_(u'Invitation accepted for room %(room)s [%(profile)s]') % {'room': room_jid.userhost(), 'profile': client.profile}) + d = self.host.plugins["XEP-0045"].join(client, room_jid, client.jid.user, {}) return d def onInvitation(self, message, profile): @@ -165,23 +161,23 @@ data = {"message": _("You have been invited by %(user)s to join the room %(room)s. Do you accept?") % {'user': from_jid_s, 'room': room_jid_s}, "title": _("MUC invitation")} self.host.askConfirmation(room_jid_s, "YES/NO", data, accept_cb, profile) - def cmd_invite(self, mess_data, profile): + def cmd_invite(self, client, mess_data): """invite someone in the room @command (group): JID - JID: the JID of the person to invite """ contact_jid_s = mess_data["unparsed"].strip() - my_host = self.host.profiles[profile].jid.host + my_host = client.jid.host try: contact_jid = jid.JID(contact_jid_s) except (RuntimeError, jid.InvalidFormat, AttributeError): feedback = _(u"You must provide a valid JID to invite, like in '/invite contact@{host}'").format(host=my_host) - self.host.plugins[C.TEXT_CMDS].feedBack(feedback, mess_data, profile) + self.host.plugins[C.TEXT_CMDS].feedBack(client, feedback, mess_data) return False if not contact_jid.user: contact_jid.user, contact_jid.host = contact_jid.host, my_host - self.invite(contact_jid, mess_data["to"], {}, profile) + self.invite(contact_jid, mess_data["to"], {}, client.profile) return False
--- a/src/plugins/plugin_xep_0334.py Fri Jun 24 22:32:58 2016 +0200 +++ b/src/plugins/plugin_xep_0334.py Fri Jun 24 22:41:28 2016 +0200 @@ -51,13 +51,13 @@ def __init__(self, host): log.info(_("Message Processing Hints plugin initialization")) self.host = host - host.trigger.add("sendMessage", self.sendMessageTrigger) + host.trigger.add("messageSend", self.messageSendTrigger) host.trigger.add("MessageReceived", self.messageReceivedTrigger) def getHandler(self, profile): return XEP_0334_handler(self, profile) - def sendMessageTrigger(self, mess_data, pre_xml_treatments, post_xml_treatments, profile): + def messageSendTrigger(self, client, mess_data, pre_xml_treatments, post_xml_treatments): """Add the hints element to the message to be sent""" hints = [] for key in ('no-permanent-storage', 'no-storage', 'no-copy'): @@ -78,7 +78,7 @@ post_xml_treatments.addCallback(treatment) return True - def messageReceivedTrigger(self, message, post_treat, profile): + def messageReceivedTrigger(self, client, message, post_treat): """Check for hints in the received message""" hints = [] for key in ('no-permanent-storage', 'no-storage'):
--- a/src/test/helpers.py Fri Jun 24 22:32:58 2016 +0200 +++ b/src/test/helpers.py Fri Jun 24 22:41:28 2016 +0200 @@ -104,7 +104,7 @@ def registerCallback(self, callback, *args, **kwargs): pass - def sendMessage(self, to_s, msg, subject=None, mess_type='auto', extra={}, profile_key='@NONE@'): + def messageSend(self, to_s, msg, subject=None, mess_type='auto', extra={}, profile_key='@NONE@'): self.sendAndStoreMessage({"to": JID(to_s)}) def _sendMessageToStream(self, mess_data, client):
--- a/src/test/test_core_xmpp.py Fri Jun 24 22:32:58 2016 +0200 +++ b/src/test/test_core_xmpp.py Fri Jun 24 22:41:28 2016 +0200 @@ -53,7 +53,7 @@ </message> """ stanza = parseXml(xml) - self.host.bridge.expectCall("newMessage", u"sender@example.net/house", u"test", u"chat", u"test@example.org/SàT", {}, profile=Const.PROFILE[0]) + self.host.bridge.expectCall("messageNew", u"sender@example.net/house", u"test", u"chat", u"test@example.org/SàT", {}, profile=Const.PROFILE[0]) self.message.onMessage(stanza)
--- a/src/test/test_plugin_xep_0033.py Fri Jun 24 22:32:58 2016 +0200 +++ b/src/test/test_plugin_xep_0033.py Fri Jun 24 22:41:28 2016 +0200 @@ -27,7 +27,6 @@ from twisted.internet import defer from wokkel.generic import parseXml from twisted.words.protocols.jabber.jid import JID -from logging import ERROR PROFILE_INDEX = 0 PROFILE = Const.PROFILE[PROFILE_INDEX] @@ -60,7 +59,7 @@ """ % (JID_STR_FROM, JID_STR_TO, JID_STR_X_TO, JID_STR_X_CC, JID_STR_X_BCC) stanza = parseXml(xml.encode("utf-8")) treatments = defer.Deferred() - self.plugin.messageReceivedTrigger(stanza, treatments, PROFILE) + self.plugin.messageReceivedTrigger(self.host.getClient(PROFILE), stanza, treatments) data = {'extra': {}} def cb(data): @@ -87,7 +86,7 @@ return mess_data def _assertAddresses(self, mess_data): - """The mess_data that we got here has been modified by self.plugin.sendMessageTrigger, + """The mess_data that we got here has been modified by self.plugin.messageSendTrigger, check that the addresses element has been added to the stanza.""" expected = self._get_mess_data()['xml'] addresses_extra = """ @@ -135,28 +134,28 @@ return defer.DeferredList(d_list).addCallback(cb_list) def _trigger(self, data): - """Execute self.plugin.sendMessageTrigger with a different logging + """Execute self.plugin.messageSendTrigger with a different logging level to not pollute the output, then check that the plugin did its job. It should abort sending the message or add the extended addressing information to the stanza. - @param data: the data to be processed by self.plugin.sendMessageTrigger + @param data: the data to be processed by self.plugin.messageSendTrigger """ pre_treatments = defer.Deferred() post_treatments = defer.Deferred() helpers.muteLogging() - self.plugin.sendMessageTrigger(data, pre_treatments, post_treatments, PROFILE) + self.plugin.messageSendTrigger(self.host.getClient[PROFILE], data, pre_treatments, post_treatments) post_treatments.callback(data) helpers.unmuteLogging() post_treatments.addCallbacks(self._assertAddresses, lambda failure: failure.trap(CancelError)) return post_treatments - def test_sendMessageTriggerFeatureNotSupported(self): + def test_messageSendTriggerFeatureNotSupported(self): # feature is not supported, abort the message self.host.memory.reinit() data = self._get_mess_data() return self._trigger(data) - def test_sendMessageTriggerFeatureSupported(self): + def test_messageSendTriggerFeatureSupported(self): # feature is supported by the main target server self.host.reinit() self.host.addFeature(JID(JID_STR_TO), plugin.NS_ADDRESS, PROFILE) @@ -164,7 +163,7 @@ d = self._trigger(data) return d.addCallback(lambda dummy: self._checkSentAndStored()) - def test_sendMessageTriggerFeatureFullySupported(self): + def test_messageSendTriggerFeatureFullySupported(self): # feature is supported by all target servers self.host.reinit() self.host.addFeature(JID(JID_STR_TO), plugin.NS_ADDRESS, PROFILE) @@ -174,7 +173,7 @@ d = self._trigger(data) return d.addCallback(lambda dummy: self._checkSentAndStored()) - def test_sendMessageTriggerFixWrongEntity(self): + def test_messageSendTriggerFixWrongEntity(self): # check that a wrong recipient entity is fixed by the backend self.host.reinit() self.host.addFeature(JID(JID_STR_TO), plugin.NS_ADDRESS, PROFILE)
--- a/src/test/test_plugin_xep_0085.py Fri Jun 24 22:32:58 2016 +0200 +++ b/src/test/test_plugin_xep_0085.py Fri Jun 24 22:41:28 2016 +0200 @@ -49,9 +49,9 @@ state, plugin.NS_CHAT_STATES) stanza = parseXml(xml.encode("utf-8")) self.host.bridge.expectCall("chatStateReceived", Const.JID_STR[1], state, Const.PROFILE[0]) - self.plugin.messageReceivedTrigger(stanza, None, Const.PROFILE[0]) + self.plugin.messageReceivedTrigger(self.host.getClient(Const.PROFILE[0]), stanza, None) - def test_sendMessageTrigger(self): + def test_messageSendTrigger(self): def cb(data): xml = data['xml'].toXml().encode("utf-8") self.assertEqualXML(xml, expected.toXml().encode("utf-8")) @@ -73,7 +73,7 @@ expected = deepcopy(mess_data['xml']) expected.addElement(state, plugin.NS_CHAT_STATES) post_treatments = defer.Deferred() - self.plugin.sendMessageTrigger(mess_data, None, post_treatments, Const.PROFILE[0]) + self.plugin.messageSendTrigger(self.host.getClient(Const.PROFILE[0]), mess_data, None, post_treatments) post_treatments.addCallback(cb) post_treatments.callback(mess_data)
--- a/src/test/test_plugin_xep_0334.py Fri Jun 24 22:32:58 2016 +0200 +++ b/src/test/test_plugin_xep_0334.py Fri Jun 24 22:41:28 2016 +0200 @@ -36,7 +36,7 @@ self.host = helpers.FakeSAT() self.plugin = XEP_0334(self.host) - def test_sendMessageTrigger(self): + def test_messageSendTrigger(self): template_xml = """ <message from='romeo@montague.net/orchard' @@ -59,7 +59,7 @@ 'extra': {key: True} } treatments = defer.Deferred() - self.plugin.sendMessageTrigger(mess_data, defer.Deferred(), treatments, C.PROFILE[0]) + self.plugin.messageSendTrigger(self.host.getClient(C.PROFILE[0]), mess_data, defer.Deferred(), treatments) if treatments.callbacks: # the trigger added a callback expected_xml = template_xml % ('<%s xmlns="urn:xmpp:hints"/>' % key) treatments.addCallback(cb, expected_xml) @@ -90,7 +90,7 @@ for key in (HINTS + ('dummy_hint',)): message = parseXml(template_xml % ('<%s xmlns="urn:xmpp:hints"/>' % key)) post_treat = defer.Deferred() - self.plugin.messageReceivedTrigger(message, post_treat, C.PROFILE[0]) + self.plugin.messageReceivedTrigger(self.host.getClient(C.PROFILE[0]), message, post_treat) if post_treat.callbacks: assert(key in ('no-permanent-storage', 'no-storage')) post_treat.addCallbacks(cb, eb)