libervia-backend/sat_frontends/quick_frontend/quick_chat.py

911 lines
32 KiB
Python

#!/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
# 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.values():
if self.parent.nick.lower() in m.lower():
self._mention = True
break
self.handleMe()
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)
@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:
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 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("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.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:]
class MessageWidget(object):
"""Base classe for widgets"""
# This class does nothing and is only used to have a common ancestor
pass
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("{}/{}".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)
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.setLocked()
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.setOccupants(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
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 setLocked(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
# messageNew signals are cached when locked
self._cache = OrderedDict()
log.debug("{wid} is now locked".format(wid=self))
def setUnlocked(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.messageNew(*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 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]
@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.setLocked()
def _resyncComplete(self):
self.sync = True
self._resync_lock = False
def occupantsClear(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.historyPrint(callback=self._resyncComplete, profile=self.profile)
return
if self.type == C.CHAT_GROUP:
self.occupantsClear()
self.host.bridge.mucOccupantsGet(
str(self.target), self.profile, callback=self.updateOccupants,
errback=log.error)
self.historyPrint(
size=C.HISTORY_LIMIT_NONE,
filters={'timestamp_start': last_message.timestamp},
callback=self._resyncComplete,
profile=self.profile)
## Widget management ##
def __str__(self):
return "Chat Widget [target: {}, type: {}, profile: {}]".format(
self.target, self.type, self.profile
)
@staticmethod
def getWidgetHash(target, profiles):
profile = list(profiles)[0]
return profile + "\n" + str(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 (str(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.values()}
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.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 updateOccupants(self, occupants):
"""Update occupants list
In opposition to setOccupants, 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 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.setLocked()
self.messages.clear()
self.historyPrint(size, filters, profile=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.setUnlocked()
def historyPrint(self, size=C.HISTORY_LIMIT_DEFAULT, filters=None, callback=None,
profile="@NONE@"):
"""Print the current history
Note: self.setUnlocked 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._onHistoryPrinted()
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
)
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 _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
extra["history"] = True
self.messages[uid] = Message(
self,
uid,
timestamp,
from_jid,
to_jid,
message,
subject,
type_,
extra,
profile,
)
self._onHistoryPrinted()
if callback is not None:
callback()
def _historyGetEb(err):
log.error(_("Can't get history: {}").format(err))
self._onHistoryPrinted()
if callback is not None:
callback()
self.host.bridge.historyGet(
str(self.host.profiles[profile].whoami.bare),
str(target),
size,
True,
{k: str(v) for k,v in filters.items()},
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(_("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
"""
if self.type == C.CHAT_GROUP:
return
self.host.bridge.messageEncryptionGet(str(self.target.bare), 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("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.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.createMessage(message)
def messageEncryptionStarted(self, session_data):
self.encrypted = True
log.debug(_("message encryption started with {target} using {encryption}").format(
target=self.target, encryption=session_data['name']))
def messageEncryptionStopped(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 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 isUserMoved(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 handleUserMoved(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.isUserMoved(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 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(str(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(
"{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 list(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("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": filename})
quick_widgets.register(QuickChat)