Mercurial > libervia-backend
view sat_frontends/quick_frontend/quick_chat.py @ 4057:e807a5434f82
tests (units): tests for plugin XEP-0167:
fix 420
author | Goffi <goffi@goffi.org> |
---|---|
date | Mon, 29 May 2023 13:38:11 +0200 |
parents | 524856bd7b19 |
children | 4b842c1fb686 |
line wrap: on
line source
#!/usr/bin/env python3 # helper class for making a SàT frontend # Copyright (C) 2009-2021 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 from sat.tools.common import data_format 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 log = getLogger(__name__) 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 # FIXME: day_format need to be settable (i18n) class Message: """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.get_nick(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.values(): if self.parent.nick.lower() in m.lower(): self._mention = True break self.handle_me() self.widgets = set() # widgets linked to this message def __str__(self): return "Message<{mess_type}> [{time}]{nick}> {message}".format( mess_type=self.type, time=self.time_text, nick=self.nick, message=self.main_message) def __contains__(self, item): return hasattr(self, item) or item in self.extra @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 = next(iter(self.message.items())) self.selected_lang = lang return mess except StopIteration: if not self.attachments: # we may have empty messages if we have attachments log.error("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.items() if "html" in k} if xhtml: # FIXME: we only return first found value for now return next(iter(xhtml.values())) @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 = "%c" if timestamp < self.parent.day_change else "%H:%M" return time.strftime(time_format, timestamp) @property def avatar(self): """avatar data or None if no avatar is found""" entity = self.from_jid contact_list = self.host.contact_lists[self.profile] try: return contact_list.getCache(entity, "avatar") except (exceptions.NotFound, KeyError): # we don't check the result as the avatar listener will be called self.host.bridge.avatar_get(entity, True, self.profile) return None @property def encrypted(self): return self.extra.get("encrypted", False) def get_nick(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("extra data is missing user nick for uid {}".format(self.uid)) return "" # FIXME: converted get_specials to list for pyjamas if self.parent.type == C.CHAT_GROUP or entity in list( contact_list.get_specials(C.CONTACT_SPECIAL_GROUP) ): return entity.resource or "" if entity.bare in contact_list: try: nicknames = contact_list.getCache(entity, "nicknames") except (exceptions.NotFound, KeyError): # we check result as listener will be called self.host.bridge.identity_get( entity.bare, ["nicknames"], True, self.profile) return entity.node or entity if nicknames: return nicknames[0] else: return ( contact_list.getCache(entity, "name", default=None) 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 handle_me(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.values(): if m.startswith("/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.items(): self.message[lang] = "* " + nick + mess[3:] @property def attachments(self): return self.extra.get(C.KEY_ATTACHMENTS) class MessageWidget: """Base classe for widgets""" # This class does nothing and is only used to have a common ancestor pass class Occupant: """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("{}/{}".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, statuses=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) assert type_ in (C.CHAT_ONE2ONE, C.CHAT_GROUP) self.current_target = target self.type = type_ self.encrypted = False # True if this session is currently encrypted self._locked = False # True when resync is in progress, avoid resynchronising twice when resync is called # and history is still being updated. For internal use only self._resync_lock = False self.set_locked() if type_ == C.CHAT_GROUP: if target.resource: raise exceptions.InternalError( "a group chat entity can't have a resource" ) if nick is None: raise exceptions.InternalError("nick must not be None for group chat") self.nick = nick self.occupants = {} self.set_occupants(occupants) else: if occupants is not None or nick is not None: raise exceptions.InternalError( "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 self.statuses = set(statuses or []) 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.on_avatar, profiles) def set_locked(self): """Set locked flag To be set when we are waiting for history/search """ # FIXME: we don't use getter/setter here because of pyjamas # TODO: use proper getter/setter once we get rid of pyjamas if self._locked: log.warning("{wid} is already locked!".format(wid=self)) return self._locked = True # message_new signals are cached when locked self._cache = OrderedDict() log.debug("{wid} is now locked".format(wid=self)) def set_unlocked(self): if not self._locked: log.debug("{wid} was already unlocked".format(wid=self)) return self._locked = False for uid, data in self._cache.items(): if uid not in self.messages: self.message_new(*data) else: log.debug("discarding message already in history: {data}, ".format(data=data)) del self._cache log.debug("{wid} is now unlocked".format(wid=self)) def post_init(self): """Method to be called by frontend after widget is initialised handle the display of history and subject """ self.history_print(profile=self.profile) if self.subject is not None: self.set_subject(self.subject) if self.host.ENCRYPTION_HANDLERS: self.get_encryption_state() def on_delete(self): if self.host.AVATARS_HANDLER: self.host.removeListener("avatar", self.on_avatar) @property def contact_list(self): return self.host.contact_lists[self.profile] @property def message_widgets_rev(self): """Return the history of MessageWidget in reverse chronological order Must be implemented by frontend """ raise NotImplementedError ## synchornisation handling ## @quick_widgets.QuickWidget.sync.setter def sync(self, state): quick_widgets.QuickWidget.sync.fset(self, state) if not state: self.set_locked() def _resync_complete(self): self.sync = True self._resync_lock = False def occupants_clear(self): """Remove all occupants Must be overridden by frontends to clear their own representations of occupants """ self.occupants.clear() def resync(self): if self._resync_lock: return self._resync_lock = True log.debug("resynchronising {self}".format(self=self)) for mess in reversed(list(self.messages.values())): if mess.type == C.MESS_TYPE_INFO: continue last_message = mess break else: # we have no message yet, we can get normal history self.history_print(callback=self._resync_complete, profile=self.profile) return if self.type == C.CHAT_GROUP: self.occupants_clear() self.host.bridge.muc_occupants_get( str(self.target), self.profile, callback=self.update_occupants, errback=log.error) self.history_print( size=C.HISTORY_LIMIT_NONE, filters={'timestamp_start': last_message.timestamp}, callback=self._resync_complete, profile=self.profile) ## Widget management ## def __str__(self): return "Chat Widget [target: {}, type: {}, profile: {}]".format( self.target, self.type, self.profile ) @staticmethod def get_widget_hash(target, profiles): profile = list(profiles)[0] return profile + "\n" + str(target.bare) @staticmethod def get_private_hash(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 (str(profile), target) def add_target(self, target): super(QuickChat, self).add_target(target) if target.resource: self.current_target = ( target ) # FIXME: tmp, must use resource priority throught contactList instead def recreate_args(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.values()} kwargs["subject"] = self.subject try: kwargs["nick"] = self.nick except AttributeError: pass def on_private_created(self, widget): """Method called when a new widget for private conversation (MUC) is created""" raise NotImplementedError def get_or_create_private_widget(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.get_or_create_widget( QuickChat, entity, type_=C.CHAT_ONE2ONE, force_hash=self.get_private_hash(self.profile, entity), on_new_widget=self.on_private_created, 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 set_occupants(self, occupants): """Set the whole list of occupants""" assert len(self.occupants) == 0 for nick, data in occupants.items(): # XXX: this log is disabled because it's really too verbose # but kept commented as it may be useful for debugging # log.debug(u"adding occupant {nick} to {room}".format( # nick=nick, room=self.target)) self.occupants[nick] = Occupant(self, data, self.profile) def update_occupants(self, occupants): """Update occupants list In opposition to set_occupants, this only add missing occupants and remove occupants who have left """ # FIXME: occupants with modified status are not handled local_occupants = set(self.occupants) updated_occupants = set(occupants) left_occupants = local_occupants - updated_occupants joined_occupants = updated_occupants - local_occupants log.debug("updating occupants for {room}:\n" "left: {left_occupants}\n" "joined: {joined_occupants}" .format(room=self.target, left_occupants=", ".join(left_occupants), joined_occupants=", ".join(joined_occupants))) for nick in left_occupants: self.removeUser(occupants[nick]) for nick in joined_occupants: self.addUser(occupants[nick]) 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("Trying to remove an unknown occupant: {}".format(nick)) else: return occupant def set_user_nick(self, nick): """Set the nick of the user, usefull for e.g. change the color of the user""" self.nick = nick def change_user_nick(self, old_nick, new_nick): """Change nick of a user in group list""" log.info("{old} is now known as {new} in room {room_jid}".format( old = old_nick, new = new_nick, room_jid = self.target)) ## Messages ## def manage_message(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 message_new @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 update_history(self, size=C.HISTORY_LIMIT_DEFAULT, filters=None, profile="@NONE@"): """Called when history need to be recreated Remove all message from history then call history_print 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.set_locked() self.messages.clear() self.history_print(size, filters, profile=profile) def _on_history_printed(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.set_unlocked() def history_print(self, size=C.HISTORY_LIMIT_DEFAULT, filters=None, callback=None, profile="@NONE@"): """Print the current history Note: self.set_unlocked will be called once history is printed @param size (int): number of messages @param search (str): pattern to filter the history results @param callback(callable, None): method to call when history has been printed @param profile (str): %(doc_profile)s """ if filters is None: filters = {} if size == 0: log.debug("Empty history requested, skipping") self._on_history_printed() return log_msg = _("now we print the history") if size != C.HISTORY_LIMIT_DEFAULT: log_msg += _(" ({} 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, default=None ) 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 self.history_filters = filters def _history_get_cb(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.print_day_change(message_day) # previous_day = message_day for data in history: uid, timestamp, from_jid, to_jid, message, subject, type_, extra_s = data from_jid = jid.JID(from_jid) to_jid = jid.JID(to_jid) extra = data_format.deserialise(extra_s) # 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._on_history_printed() if callback is not None: callback() def _history_get_eb(err): log.error(_("Can't get history: {}").format(err)) self._on_history_printed() if callback is not None: callback() self.host.bridge.history_get( str(self.host.profiles[profile].whoami.bare), str(target), size, True, {k: str(v) for k,v in filters.items()}, profile, callback=_history_get_cb, errback=_history_get_eb, ) def message_encryption_get_cb(self, session_data): if session_data: session_data = data_format.deserialise(session_data) self.message_encryption_started(session_data) def message_encryption_get_eb(self, failure_): log.error(_("Can't get encryption state: {reason}").format(reason=failure_)) def get_encryption_state(self): """Retrieve encryption state with current target. Once state is retrieved, default message_encryption_started will be called if suitable """ if self.type == C.CHAT_GROUP: return self.host.bridge.message_encryption_get(str(self.target.bare), self.profile, callback=self.message_encryption_get_cb, errback=self.message_encryption_get_eb) def message_new(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 ((not msg and not subject and not extra[C.KEY_ATTACHMENTS] and type_ != C.MESS_TYPE_INFO)): log.warning("Received an empty message for uid {}".format(uid)) 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.get_or_create_private_widget(to_jid) chat_widget.message_new( 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.items() 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("Delayed message received after history, this should not happen") self.create_message(message) def message_encryption_started(self, session_data): self.encrypted = True log.debug(_("message encryption started with {target} using {encryption}").format( target=self.target, encryption=session_data['name'])) def message_encryption_stopped(self, session_data): self.encrypted = False log.debug(_("message encryption stopped with {target} (was using {encryption})") .format(target=self.target, encryption=session_data['name'])) def create_message(self, message, append=False): """Must be implemented by frontend to create and show a new message widget This is only called on message_new, not on history. You need to override history_print to handle the later @param message(Message): message data """ raise NotImplementedError def is_user_moved(self, message): """Return True if message is a user left/joined message @param message(Message): message to check @return (bool): True is message is user moved info message """ 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 ROOM_USER_MOVED def handle_user_moved(self, message): """Check if this message is a UserMoved one, and merge it when possible "merge it" means that info message indicating a user joined/left will be grouped if no other non-info messages has been sent since @param message(Message): message to check @return (bool): True if this message has been merged if True, a new MessageWidget must not be created and appended to history """ if self.is_user_moved(message): for wid in self.message_widgets_rev: # we merge in/out messages if no message was sent meanwhile if not isinstance(wid, MessageWidget): continue elif wid.mess_data.type != C.MESS_TYPE_INFO: return False elif ( wid.info_type in 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 == ROOM_USER_LEFT: wid.message = _("<= {nick} has left the room ({count})").format( nick=nick, count=count ) else: wid.message = _( "<=> {nick} re-entered the room ({count})" ).format(nick=nick, count=count) wid.reentered_count += 1 return True return False def print_day_change(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 set_subject(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 change_subject(self, new_subject): """Change the subject of the room This change the subject on the room itself (i.e. via XMPP), while set_subject change the subject of this widget """ self.host.bridge.muc_subject(str(self.target), new_subject, self.profile) def add_game_panel(self, widget): """Insert a game panel to this Chat dialog. @param widget (Widget): the game panel """ raise NotImplementedError def remove_game_panel(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 on_chat_state(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( "{nick} not found in {room}, ignoring new chat state".format( nick=nick, room=self.target.bare ) ) def on_message_state(self, uid, status, profile): try: mess_data = self.messages[uid] except KeyError: pass else: mess_data.status = status def on_avatar(self, entity, avatar_data, profile): if self.type == C.CHAT_GROUP: if entity.bare == self.target: try: self.occupants[entity.resource].update({"avatar": avatar_data}) except KeyError: # can happen for a message in history where the # entity is not here anymore pass for m in list(self.messages.values()): if m.nick == entity.resource: for w in m.widgets: w.update({"avatar": avatar_data}) else: if ( entity.bare == self.target.bare or entity.bare == self.host.profiles[profile].whoami.bare ): log.info("avatar updated for {}".format(entity)) for m in list(self.messages.values()): if m.from_jid.bare == entity.bare: for w in m.widgets: w.update({"avatar": avatar_data}) quick_widgets.register(QuickChat)