Mercurial > libervia-backend
view sat_frontends/quick_frontend/quick_chat.py @ 2661:661f66d41215
core (xmpp): send initial presence only after all profileConnected have been treated:
presence is now sent after profileConnected methods are done, this avoid to have to deal with synchronisation in connection event.
For instance, PEP events should not be sent before presence is sent, so profileConnected methods can assume PEP events are not
done yet, and do needed initialisation using async method if necessary.
This has been done to avoid overcomplicated synchronisation in XEP-0384 plugin (network calls are needed to initialise session,
but PEP events need an initialised session to be treated).
author | Goffi <goffi@goffi.org> |
---|---|
date | Sat, 11 Aug 2018 18:24:55 +0200 |
parents | 96911768b0f3 |
children | e35a265ec174 |
line wrap: on
line source
#!/usr/bin/env python2 # -*- coding: utf-8 -*- # helper class for making a SAT frontend # Copyright (C) 2009-2018 Jérôme Poisson (goffi@goffi.org) # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. from sat.core.i18n import _ from sat.core.log import getLogger log = getLogger(__name__) from sat.core import exceptions from sat_frontends.quick_frontend import quick_widgets from sat_frontends.quick_frontend.constants import Const as C from collections import OrderedDict from sat_frontends.tools import jid import time try: from locale import getlocale except ImportError: # FIXME: pyjamas workaround getlocale = lambda x: (None, "utf-8") 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 unicode("") # XXX: unicode doesn't exist in pyjamas 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) self._status = None # 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) ) # is user mentioned here ? if self.parent.type == C.CHAT_GROUP and not self.own_mess: for m in msg.itervalues(): if self.parent.nick.lower() in m.lower(): self._mention = True break self.handleMe() 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") @property def mention(self): try: return self._mention except AttributeError: return False @property def history(self): """True if message come from history""" return self.extra.get("history", False) @property def main_message(self): """currently displayed message""" if self.parent.lang in self.message: self.selected_lang = self.parent.lang return self.message[self.parent.lang] try: self.selected_lang = "" return self.message[""] except KeyError: try: lang, mess = self.message.iteritems().next() self.selected_lang = lang return mess except StopIteration: log.error(u"Can't find message for uid {}".format(self.uid)) return "" @property def main_message_xhtml(self): """rich message""" xhtml = {k: v for k, v in self.extra.iteritems() if "html" in k} if xhtml: # FIXME: we only return first found value for now return next(xhtml.itervalues()) @property def time_text(self): """Return timestamp in a nicely formatted way""" # if the message was sent before today, we print the full date timestamp = time.localtime(self.timestamp) time_format = u"%c" if timestamp < self.parent.day_change else u"%H:%M" return time.strftime(time_format, timestamp).decode(getlocale()[1] or "utf-8") @property def avatar(self): """avatar full path or None if no avatar is found""" ret = self.host.getAvatar(self.from_jid, profile=self.profile) return ret 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 "" # FIXME: converted getSpecials to list for pyjamas if self.parent.type == C.CHAT_GROUP or entity in list( contact_list.getSpecials(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 @property def status(self): return self._status @status.setter def status(self, status): if status != self._status: self._status = status for w in self.widgets: w.update({"status": status}) def handleMe(self): """Check if messages starts with "/me " and change them if it is the case if several messages (different languages) are presents, they all need to start with "/me " """ # TODO: XHTML-IM /me are not handled me = False # we need to check /me for every message for m in self.message.itervalues(): if m.startswith(u"/me "): me = True else: me = False break if me: self.type = C.MESS_TYPE_INFO self.extra["info_type"] = "me" nick = self.nick for lang, mess in self.message.iteritems(): self.message[lang] = u"* " + nick + mess[3:] 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") self.affiliation = data["affiliation"] self.role = data["role"] self.widgets = set() # widgets linked to this occupant self._state = None @property def data(self): """reconstruct data dict from attributes""" data = {} data["nick"] = self.nick if self._entity is not None: data["entity"] = self._entity data["affiliation"] = self.affiliation data["role"] = self.role return data @property def jid(self): """jid in the room""" return jid.JID(u"{}/{}".format(self.parent.target.bare, self.nick)) @property def real_jid(self): """real jid if known else None""" return self._entity @property def host(self): return self.parent.host @property def state(self): return self._state @state.setter def state(self, new_state): if new_state != self._state: self._state = new_state for w in self.widgets: w.update({"state": new_state}) def update(self, update_dict=None): for w in self.widgets: w.update(update_dict) class QuickChat(quick_widgets.QuickWidget): visible_states = ["chat_state"] # FIXME: to be removed, used only in quick_games def __init__( self, host, target, type_=C.CHAT_ONE2ONE, nick=None, 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 = True # True when we are waiting for history/search # messageNew signals are cached when locked self._cache = OrderedDict() assert type_ in (C.CHAT_ONE2ONE, C.CHAT_GROUP) self.current_target = target self.type = type_ if type_ == C.CHAT_GROUP: if target.resource: raise exceptions.InternalError( u"a group chat entity can't have a resource" ) if nick is None: raise exceptions.InternalError(u"nick must not be None for group chat") self.nick = nick self.occupants = {} self.setOccupants(occupants) else: if occupants is not None or nick is not None: raise exceptions.InternalError( u"only group chat can have occupants or nick" ) self.messages = OrderedDict() # key: uid, value: Message instance self.games = {} # key=game name (unicode), value=instance of quick_games.RoomGame self.subject = subject lt = time.localtime() self.day_change = ( lt.tm_year, lt.tm_mon, lt.tm_mday, 0, 0, 0, lt.tm_wday, lt.tm_yday, lt.tm_isdst, ) # struct_time of day changing time if self.host.AVATARS_HANDLER: self.host.addListener("avatar", self.onAvatar, profiles) 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) def onDelete(self): if self.host.AVATARS_HANDLER: self.host.removeListener("avatar", self.onAvatar) @property def contact_list(self): return self.host.contact_lists[self.profile] ## Widget management ## def __str__(self): return u"Chat Widget [target: {}, type: {}, profile: {}]".format( self.target, self.type, self.profile ) @staticmethod def getWidgetHash(target, profiles): profile = list(profiles)[0] return profile + "\n" + unicode(target.bare) @staticmethod def getPrivateHash(target, profile): """Get unique hash for private conversations This method should be used with force_hash to get unique widget for private MUC conversations """ return (unicode(profile), target) def addTarget(self, target): super(QuickChat, self).addTarget(target) if target.resource: self.current_target = ( target ) # FIXME: tmp, must use resource priority throught contactList instead def recreateArgs(self, args, kwargs): """copy important attribute for a new widget""" kwargs["type_"] = self.type if self.type == C.CHAT_GROUP: kwargs["occupants"] = {o.nick: o.data for o in self.occupants.itervalues()} kwargs["subject"] = self.subject try: kwargs["nick"] = self.nick except AttributeError: pass def onPrivateCreated(self, widget): """Method called when a new widget for private conversation (MUC) is created""" raise NotImplementedError def getOrCreatePrivateWidget(self, entity): """Create a widget for private conversation, or get it if it already exists @param entity: full jid of the target """ 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 @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: occupant = self.occupants.pop(nick) except KeyError: log.warning(u"Trying to remove an unknown occupant: {}".format(nick)) else: return occupant def setUserNick(self, nick): """Set the nick of the user, usefull for e.g. change the color of the user""" self.nick = nick 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: if mess_type != C.MESS_TYPE_GROUPCHAT and entity in self.targets: return True return False def updateHistory(self, size=C.HISTORY_LIMIT_DEFAULT, filters=None, 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 filters (str): patterns to filter the history results @param profile (str): %(doc_profile)s """ self._locked = True self._cache = OrderedDict() self.messages.clear() self.historyPrint(size, filters, profile) def _onHistoryPrinted(self): """Method called when history is printed (or failed) 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.itervalues(): self.messageNew(*data) del self._cache def historyPrint(self, size=C.HISTORY_LIMIT_DEFAULT, filters=None, 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 """ if filters is None: filters = {} 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) if self.type == C.CHAT_ONE2ONE: special = self.host.contact_lists[self.profile].getCache( self.target, C.CONTACT_SPECIAL, create_if_not_found=True ) if special == C.CONTACT_SPECIAL_GROUP: # we have a private conversation # so we need full jid for the history # (else we would get history from group itself) # and to filter out groupchat message target = self.target filters["not_types"] = C.MESS_TYPE_GROUPCHAT else: target = self.target.bare else: # groupchat target = self.target.bare # FIXME: info not handled correctly filters["types"] = C.MESS_TYPE_GROUPCHAT 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 # cached messages may already be in history # so we check it to avoid duplicates, they'll be added later if uid in self._cache: continue 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 extra["history"] = True 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: {}").format(err)) self._onHistoryPrinted() self.host.bridge.historyGet( unicode(self.host.profiles[profile].whoami.bare), unicode(target), size, True, filters, profile, callback=_historyGetCb, errback=_historyGetEb, ) def messageNew( self, uid, timestamp, from_jid, to_jid, msg, subject, type_, extra, profile ): if self._locked: self._cache[uid] = ( 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) 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 """ raise NotImplementedError def printDayChange(self, day): """Display the day on a new line. @param day(unicode): day to display (or not if this method is not overwritten) """ # FIXME: not called anymore after refactoring pass ## Room ## def setSubject(self, subject): """Set title for a group chat""" if self.type != C.CHAT_GROUP: raise exceptions.InternalError( "trying to set subject for a non group chat window" ) self.subject = subject def changeSubject(self, new_subject): """Change the subject of the room This change the subject on the room itself (i.e. via XMPP), while setSubject change the subject of this widget """ self.host.bridge.mucSubject(unicode(self.target), new_subject, self.profile) def addGamePanel(self, widget): """Insert a game panel to this Chat dialog. @param widget (Widget): the game panel """ raise NotImplementedError def removeGamePanel(self, widget): """Remove the game panel from this Chat dialog. @param widget (Widget): the game panel """ raise NotImplementedError def update(self, entity=None): """Update one or all entities. @param entity (jid.JID): entity to update """ # FIXME: to remove ? raise NotImplementedError ## events ## def onChatState(self, from_jid, state, profile): """A chat state has been received""" if self.type == C.CHAT_GROUP: nick = from_jid.resource try: self.occupants[nick].state = state except KeyError: log.warning( u"{nick} not found in {room}, ignoring new chat state".format( nick=nick, room=self.target.bare ) ) def onMessageState(self, uid, status, profile): try: mess_data = self.messages[uid] except KeyError: pass else: mess_data.status = status def onAvatar(self, entity, filename, profile): if self.type == C.CHAT_GROUP: if entity.bare == self.target: try: self.occupants[entity.resource].update({"avatar": filename}) except KeyError: # can happen for a message in history where the # entity is not here anymore pass for m in self.messages.values(): if m.nick == entity.resource: for w in m.widgets: w.update({"avatar": filename}) else: if ( entity.bare == self.target.bare or entity.bare == self.host.profiles[profile].whoami.bare ): log.info(u"avatar updated for {}".format(entity)) for m in self.messages.values(): if m.from_jid.bare == entity.bare: for w in m.widgets: w.update({"avatar": filename}) quick_widgets.register(QuickChat)