/!\ huge commit Avatar logic has been reworked around the IDENTITY plugin: plugins able to handle avatar or other identity related metadata (like nicknames) register to IDENTITY plugin in the same way as for other features like download/upload. Once registered, IDENTITY plugin will call them when suitable in order of priority, and handle caching. Methods to manage those metadata from frontend now use serialised data. For now `avatar` and `nicknames` are handled: - `avatar` is now a dict with `path` + metadata like `media_type`, instead of just a string path - `nicknames` is now a list of nicknames in order of priority. This list is never empty, and `nicknames[0]` should be the preferred nickname to use by frontends in most cases. In addition to contact specified nicknames, user set nickname (the one set in roster) is used in priority when available. Among the side changes done with this commit, there are: - a new `contactGet` bridge method to get roster metadata for a single contact - SatPresenceProtocol.send returns a Deferred to check when it has actually been sent - memory's methods to handle entities data now use `client` as first argument - metadata filter can be specified with `getIdentity` - `getAvatar` and `setAvatar` are now part of the IDENTITY plugin instead of XEP-0054 (and there signature has changed) - `isRoom` and `getBareOrFull` are now part of XEP-0045 plugin - jp avatar/get command uses `xdg-open` first when available for `--show` flag - `--no-cache` has been added to jp avatar/get and identity/get - jp identity/set has been simplified, explicit options (`--nickname` only for now) are used instead of `--field`. `--field` may come back in the future if necessary for extra data. - QuickContactList `SetContact` now handle None as a value, and doesn't use it to delete the metadata anymore - improved cache handling for `metadata` and `nicknames` in quick frontend - new `default` argument in QuickContactList `getCache`
941 lines
33 KiB
Python
941 lines
33 KiB
Python
#!/usr/bin/env python3
|
|
|
|
# helper class for making a SàT frontend
|
|
# Copyright (C) 2009-2020 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.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)
|
|
|
|
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.avatarGet(entity, True, self.profile)
|
|
return None
|
|
|
|
@property
|
|
def encrypted(self):
|
|
return self.extra.get("encrypted", False)
|
|
|
|
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:
|
|
|
|
try:
|
|
nicknames = contact_list.getCache(entity, "nicknames")
|
|
except (exceptions.NotFound, KeyError):
|
|
# we check result as listener will be called
|
|
self.host.bridge.identityGet(
|
|
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 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:]
|
|
|
|
@property
|
|
def attachments(self):
|
|
return self.extra.get(C.MESS_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.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
|
|
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.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, 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 _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_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._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 not extra[C.MESS_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.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, 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)
|