view sat_frontends/quick_frontend/quick_chat.py @ 2822:5284be4c601b

plugin XEP-0045: cache presences if they are received before the room is fully joined: presences are then replayed (only the last one) once the room is fully joined.
author Goffi <goffi@goffi.org>
date Fri, 01 Mar 2019 12:16:57 +0100
parents 4a05e67a8025
children 76f714b12d21
line wrap: on
line source

#!/usr/bin/env python2
# -*- coding: utf-8 -*-

# helper class for making a SAT frontend
# Copyright (C) 2009-2019 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__)

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_
        self.encrypted = False  # True if this session is currently encrypted
        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)
        if self.host.ENCRYPTION_HANDLERS:
            self.getEncryptionState()

    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"""
        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 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 messageEncryptionGetCb(self, session_data):
        if session_data:
            session_data = data_format.deserialise(session_data)
            self.messageEncryptionStarted(session_data)

    def messageEncryptionGetEb(self, failure_):
        log.error(_(u"Can't get encryption state: {reason}").format(reason=failure_))

    def getEncryptionState(self):
        """Retrieve encryption state with current target.

        Once state is retrieved, default messageEncryptionStarted will be called if
        suitable
        """
        self.host.bridge.messageEncryptionGet(unicode(self.target), self.profile,
                                              callback=self.messageEncryptionGetCb,
                                              errback=self.messageEncryptionGetEb)


    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 not msg and not subject and type_ != C.MESS_TYPE_INFO:
            log.warning(u"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.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 messageEncryptionStarted(self, session_data):
        self.encrypted = True
        log.debug(_(u"message encryption started with {target} using {encryption}").format(
            target=self.target, encryption=session_data[u'name']))

    def messageEncryptionStopped(self, session_data):
        self.encrypted = False
        log.debug(_(u"message encryption stopped with {target} (was using {encryption})")
                 .format(target=self.target, encryption=session_data[u'name']))

    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)