Mercurial > libervia-backend
changeset 1963:a2bc5089c2eb
backend, frontends: message refactoring (huge commit):
/!\ several features are temporarily disabled, like notifications in frontends
next step in refactoring, with the following changes:
- jp: updated jp message to follow changes in backend/bridge
- jp: added --lang, --subject, --subject_lang, and --type options to jp message + fixed unicode handling for jid
- quick_frontend (QuickApp, QuickChat):
- follow backend changes
- refactored chat, message are now handled in OrderedDict and uid are kept so they can be updated
- Message and Occupant classes handle metadata, so frontend just have to display them
- Primitivus (Chat):
- follow backend/QuickFrontend changes
- info & standard messages are handled in the same MessageWidget class
- improved/simplified handling of messages, removed update() method
- user joined/left messages are merged when next to each other
- a separator is shown when message is received while widget is out of focus, so user can quickly see the new messages
- affiliation/role are shown (in a basic way for now) in occupants panel
- removed "/me" messages handling, as it will be done by a backend plugin
- message language is displayed when available (only one language per message for now)
- fixed :history and :search commands
- core (constants): new constants for messages type, XML namespace, entity type
- core: *Message methods renamed to follow new code sytle (e.g. sendMessageToBridge => messageSendToBridge)
- core (messages handling): fixed handling of language
- core (messages handling): mes_data['from'] and ['to'] are now jid.JID
- core (core.xmpp): reorganised message methods, added getNick() method to client.roster
- plugin text commands: fixed plugin and adapted to new messages behaviour. client is now used in arguments instead of profile
- plugins: added information for cancellation reason in CancelError calls
- plugin XEP-0045: various improvments, but this plugin still need work:
- trigger is used to avoid message already handled by the plugin to be handled a second time
- changed the way to handle history, the last message from DB is checked and we request only messages since this one, in seconds (thanks Poezio folks :))
- subject reception is waited before sending the roomJoined signal, this way we are sure that everything including history is ready
- cmd_* method now follow the new convention with client instead of profile
- roomUserJoined and roomUserLeft messages are removed, the events are now handled with info message with a "ROOM_USER_JOINED" info subtype
- probably other forgotten stuffs :p
line wrap: on
line diff
--- a/frontends/src/bridge/DBus.py Sun Jun 19 22:22:13 2016 +0200 +++ b/frontends/src/bridge/DBus.py Mon Jun 20 18:41:53 2016 +0200 @@ -554,7 +554,7 @@ 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): + 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:
--- a/frontends/src/jp/cmd_message.py Sun Jun 19 22:22:13 2016 +0200 +++ b/frontends/src/jp/cmd_message.py Mon Jun 20 18:41:53 2016 +0200 @@ -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/primitivus/chat.py Sun Jun 19 22:22:13 2016 +0200 +++ b/frontends/src/primitivus/chat.py Mon Jun 20 18:41:53 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,42 +95,103 @@ 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) + 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.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) @@ -94,16 +199,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 @@ -115,11 +232,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: @@ -160,76 +277,87 @@ @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._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: @@ -274,55 +402,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. @@ -330,12 +435,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: @@ -354,10 +460,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 Sun Jun 19 22:22:13 2016 +0200 +++ b/frontends/src/primitivus/constants.py Mon Jun 20 18:41:53 2016 +0200 @@ -32,10 +32,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/primitivus Sun Jun 19 22:22:13 2016 +0200 +++ b/frontends/src/primitivus/primitivus Mon Jun 20 18:41:53 2016 +0200 @@ -99,7 +99,7 @@ self.host.messageSend( chat_widget.target, {'': editBar.get_edit_text()}, # TODO: handle language - mess_type = "groupchat" if chat_widget.type == 'group' else "chat", # TODO: put this in QuickChat + 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 ) @@ -139,19 +139,15 @@ 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) else: return self.set_edit_text('') @@ -540,7 +536,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, @@ -556,6 +553,12 @@ 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 self.contact_lists.select(None) @@ -722,11 +725,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) - for contact_list in self.widgets.getWidgets(ContactList): - if profile in contact_list.profiles: - contact_list.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) @@ -745,7 +749,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):
--- a/frontends/src/quick_frontend/quick_app.py Sun Jun 19 22:22:13 2016 +0200 +++ b/frontends/src/quick_frontend/quick_app.py Mon Jun 20 18:41:53 2016 +0200 @@ -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) @@ -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") @@ -490,11 +481,8 @@ 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 @@ -560,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""" @@ -578,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) @@ -613,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.
--- a/frontends/src/quick_frontend/quick_chat.py Sun Jun 19 22:22:13 2016 +0200 +++ b/frontends/src/quick_frontend/quick_chat.py Mon Jun 20 18:41:53 2016 +0200 @@ -20,11 +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 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 @@ -32,26 +38,109 @@ 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 + def postInit(self): + """Method to be called by frontend after widget is initialised + + 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) @@ -74,109 +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 messageNew - @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""" - 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 _historyGetCb(history): - day_format = "%A, %d %b %Y" # to display the day change - previous_day = datetime.now().strftime(day_format) - for data in history: - uid, timestamp, from_jid, to_jid, message, subject, type_, extra = data # 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(timestamp).strftime(day_format) - if previous_day != message_day: - self.printDayChange(message_day) - previous_day = message_day - extra["timestamp"] = timestamp - self.messageNew(uid, timestamp, jid.JID(from_jid), target, message, subject, type_, extra, profile) - self.afterHistoryPrint() - - def _historyGetEb(err): - log.error(_("Can't get history")) - - self.host.bridge.historyGet(unicode(self.host.profiles[profile].whoami.bare), unicode(target), size, True, search, profile, callback=_historyGetCb, errback=_historyGetEb) - - 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 @@ -188,57 +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 messageNew(self, uid, timestamp, from_jid, target, msg, subject, type_, extra, profile): + @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: - msg = msg.itervalues().next() # FIXME: tmp fix until message refactoring is finished (msg is now a dict) - except StopIteration: - log.warning(u"No message found (uid: {})".format(uid)) - msg = '' - 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.messageNew(uid, timestamp, from_jid, target, msg, subject, type_, extra, profile) - return + occupant = self.occupants.pop(nick) + except KeyError: + log.warning(u"Trying to remove an unknown occupant: {}".format(nick)) + else: + return occupant - if type_ == C.MESS_TYPE_INFO: - self.printInfo(msg, extra=extra) + 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)) + + ## Messages ## + + 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 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: - nick = self._get_nick(from_jid) - if msg.startswith('/me '): - self.printInfo('* {} {}'.format(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) - # FIXME: to be checked/removed after message refactoring - # if timestamp: - self.afterHistoryPrint() + 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) - def printMessage(self, nick, my_message, message, timestamp, extra=None, profile=C.PROF_KEY_NONE): - """Print message in chat window. + 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) - @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 + 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 """ - # FIXME: check/remove this if necessary (message refactoring) - # 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) + 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() + + 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. @@ -246,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): @@ -253,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/src/bridge/DBus.py Sun Jun 19 22:22:13 2016 +0200 +++ b/src/bridge/DBus.py Mon Jun 20 18:41:53 2016 +0200 @@ -425,7 +425,7 @@ @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): + 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,
--- a/src/bridge/bridge_constructor/bridge_template.ini Sun Jun 19 22:22:13 2016 +0200 +++ b/src/bridge/bridge_constructor/bridge_template.ini Mon Jun 20 18:41:53 2016 +0200 @@ -51,6 +51,7 @@ - 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] @@ -447,7 +448,7 @@ category=core 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@"
--- a/src/core/constants.py Sun Jun 19 22:22:13 2016 +0200 +++ b/src/core/constants.py Mon Jun 20 18:41:53 2016 +0200 @@ -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'
--- a/src/core/sat_main.py Sun Jun 19 22:22:13 2016 +0200 +++ b/src/core/sat_main.py Mon Jun 20 18:41:53 2016 +0200 @@ -95,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("historyGet", self.memory.historyGet) + 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) @@ -553,11 +553,11 @@ for lang, subject in data["subject"].iteritems(): subject_elt = message_elt.addElement("subject", content=subject) if lang: - subject_elt['xml:lang'] = 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['xml:lang'] = lang + body_elt[(C.NS_XML, 'lang')] = lang try: thread = data['extra']['thread'] except KeyError: @@ -646,21 +646,21 @@ 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.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 _cancelErrorTrap(failure): + def _cancelErrorTrap(self, failure): """A message sending can be cancelled by a plugin treatment""" failure.trap(exceptions.CancelError) - def _sendMessageToStream(self, data, client): + def messageSendToStream(self, data, client): """Actualy send the message to the server @param data: message data dictionnary @@ -669,7 +669,7 @@ client.xmlstream.send(data['xml']) return data - def _storeMessage(self, data, client): + def messageAddToHistory(self, data, client): """Store message into database (for local history) @param data: message data dictionnary @@ -678,13 +678,13 @@ 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 data['message']: # we need a message to save something + if data['message'] or data['subject']: # we need a message to store self.memory.addToHistory(client, data) else: log.warning(u"No message found") # empty body should be managed by plugins before this point return data - def sendMessageToBridge(self, data, client): + def messageSendToBridge(self, data, client): """Send message to bridge, so frontends can display it @param data: message data dictionnary @@ -693,7 +693,7 @@ 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 data['message']: # we need a message to send something + 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: @@ -1039,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 Sun Jun 19 22:22:13 2016 +0200 +++ b/src/core/xmpp.py Mon Jun 20 18:41:53 2016 +0200 @@ -25,7 +25,7 @@ from twisted.words.protocols.jabber import error from twisted.words.protocols.jabber import jid from twisted.python import failure -from wokkel import client, disco, xmppim, generic, iwokkel +from wokkel import client as wokkel_client, disco, xmppim, generic, iwokkel from wokkel import delay from sat.core.log import getLogger log = getLogger(__name__) @@ -36,13 +36,13 @@ 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 @@ -80,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() @@ -112,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 @@ -152,8 +152,8 @@ message = {} subject = {} extra = {} - data = {"from": message_elt['from'], - "to": message_elt['to'], + 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, @@ -169,11 +169,11 @@ # message for e in message_elt.elements(C.NS_CLIENT, 'body'): - message[e.getAttribute('xml:lang','')] = unicode(e) + message[e.getAttribute((C.NS_XML,'lang'),'')] = unicode(e) # subject for e in message_elt.elements(C.NS_CLIENT, 'subject'): - subject[e.getAttribute('xml:lang','')] = unicode(e) + subject[e.getAttribute((C.NS_XML, 'lang'),'')] = unicode(e) # delay and timestamp try: @@ -187,41 +187,38 @@ if parsed_delay.sender: data['delay_sender'] = parsed_delay.sender.full() - def skipEmptyMessage(data): - if not data['message'] and not data['extra']: - raise failure.Failure(exceptions.CancelError()) - return data + + 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 bridgeSignal(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 + 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 addToHistory(data): - data['from'] = jid.JID(data['from']) - data['to'] = jid.JID(data['to']) - self.host.memory.addToHistory(client, data) - return data - - def treatmentsEb(failure_): - failure_.trap(exceptions.SkipHistory) - return 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(skipEmptyMessage) - 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): @@ -283,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) @@ -339,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) @@ -384,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): @@ -474,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) @@ -494,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 @@ -505,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'))
--- a/src/memory/memory.py Sun Jun 19 22:22:13 2016 +0200 +++ b/src/memory/memory.py Mon Jun 20 18:41:53 2016 +0200 @@ -513,6 +513,9 @@ def addToHistory(self, client, data): return self.storage.addToHistory(data, client.profile) + 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 @@ -525,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 message data as in [messageNew] + @return (D(list)): list of message data as in [messageNew] """ assert profile != C.PROF_KEY_NONE if limit == C.HISTORY_LIMIT_DEFAULT: @@ -534,7 +537,7 @@ limit = None if limit == 0: return defer.succeed([]) - return self.storage.historyGet(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/plugins/plugin_exp_command_export.py Sun Jun 19 22:22:13 2016 +0200 +++ b/src/plugins/plugin_exp_command_export.py Mon Jun 20 18:41:53 2016 +0200 @@ -103,14 +103,14 @@ except ValueError: pass - def MessageReceivedTrigger(self, client, message, post_treat): + 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"]) + from_jid = jid.JID(message_elt["from"]) spawned_key = (from_jid.userhostJID(), client.profile) if spawned_key in self.spawned: try: - body = message.elements(C.NS_CLIENT, 'body').next() + body = message_elt.elements(C.NS_CLIENT, 'body').next() except StopIteration: # do not block message without body (chat state notification...) return True
--- a/src/plugins/plugin_exp_parrot.py Sun Jun 19 22:22:13 2016 +0200 +++ b/src/plugins/plugin_exp_parrot.py Mon Jun 20 18:41:53 2016 +0200 @@ -67,12 +67,12 @@ # log.debug("Parrot link detected, skipping other triggers") # raise trigger.SkipOtherTriggers - def MessageReceivedTrigger(self, client, message, post_treat): + 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 @@ -83,7 +83,7 @@ return True message = {} - for e in message.elements(C.NS_CLIENT, 'body'): + for e in message_elt.elements(C.NS_CLIENT, 'body'): body = unicode(e) lang = e.getAttribute('lang') or '' @@ -106,12 +106,12 @@ 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_text_commands.py Sun Jun 19 22:22:13 2016 +0200 +++ b/src/plugins/plugin_misc_text_commands.py Mon Jun 20 18:41:53 2016 +0200 @@ -46,6 +46,7 @@ CMD_KEY = "@command" CMD_TYPES = ('group', 'one2one', 'all') +FEEDBACK_INFO_TYPE = "TEXT_CMD" class TextCommands(object): @@ -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)) @@ -182,7 +183,6 @@ @param mess_data(dict): data comming from messageSend trigger @param profile: %(doc_profile)s """ - profile = client.profile try: msg = mess_data["message"][''] msg_lang = '' @@ -217,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): @@ -225,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) @@ -276,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.messageNew(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] @@ -299,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") @@ -312,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) @@ -345,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] @@ -355,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 @@ -384,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 Sun Jun 19 22:22:13 2016 +0200 +++ b/src/plugins/plugin_sec_otr.py Mon Jun 20 18:41:53 2016 +0200 @@ -209,7 +209,7 @@ self.skipped_profiles = set() host.trigger.add("MessageReceived", self.MessageReceivedTrigger, priority=100000) host.trigger.add("messageSend", self.messageSendTrigger, priority=100000) - host.bridge.addMethod("skipOTR", ".plugin", in_sign='s', out_sign='', method=self._skipOTR) + 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) @@ -237,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 @@ -406,7 +409,7 @@ 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 @@ -437,7 +440,7 @@ 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, @@ -450,7 +453,7 @@ raise failure.Failure(exceptions.SkipHistory()) return data - def MessageReceivedTrigger(self, client, message, post_treat): + def MessageReceivedTrigger(self, client, message_elt, post_treat): profile = client.profile if profile in self.skipped_profiles: post_treat.addCallback(self._receivedTreatmentForSkippedProfiles, profile) @@ -478,7 +481,7 @@ log.warning(u"No message found") return False otrctx.sendMessage(0, msg.encode('utf-8')) - self.host.sendMessageToBridge(mess_data, client) + 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.") self.host.bridge.messageNew(to_jid.full(),
--- a/src/plugins/plugin_xep_0033.py Sun Jun 19 22:22:13 2016 +0200 +++ b/src/plugins/plugin_xep_0033.py Mon Jun 20 18:41:53 2016 +0200 @@ -86,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"]}) @@ -99,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 @@ -124,9 +124,9 @@ 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)
--- a/src/plugins/plugin_xep_0045.py Sun Jun 19 22:22:13 2016 +0200 +++ b/src/plugins/plugin_xep_0045.py Mon Jun 20 18:41:53 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 Sun Jun 19 22:22:13 2016 +0200 +++ b/src/plugins/plugin_xep_0048.py Mon Jun 20 18:41:53 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 Sun Jun 19 22:22:13 2016 +0200 +++ b/src/plugins/plugin_xep_0071.py Mon Jun 20 18:41:53 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 import exceptions from sat.core.log import getLogger log = getLogger(__name__) @@ -91,7 +92,7 @@ @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): + def converted(xhtml, lang): if lang: data['extra']['xhtml_{}'.format(lang)] = xhtml else: @@ -99,7 +100,7 @@ defers = [] for body_elt in body_elts: - lang = body_elt.getAttribute('xml:lang', '') + 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) @@ -121,7 +122,7 @@ def syntax_converted(xhtml_im, lang): body_elt = html_elt.addElement((NS_XHTML, 'body')) if lang: - body_elt['xml:lang'] = lang + body_elt[(C.NS_XML, 'lang')] = lang data['extra']['xhtml_{}'.format(lang)] = xhtml_im else: data['extra']['xhtml'] = xhtml_im
--- a/src/plugins/plugin_xep_0092.py Sun Jun 19 22:22:13 2016 +0200 +++ b/src/plugins/plugin_xep_0092.py Mon Jun 20 18:41:53 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_0249.py Sun Jun 19 22:22:13 2016 +0200 +++ b/src/plugins/plugin_xep_0249.py Mon Jun 20 18:41:53 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