# HG changeset patch # User Goffi # Date 1586890833 -7200 # Node ID 6cf4bd6972c2c1bb1c20f9fc62a94b718be948fa # Parent 1af840e84af728b7767f9068a81ac26a8ee07040 core, frontends: avatar refactoring: /!\ 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` diff -r 1af840e84af7 -r 6cf4bd6972c2 doc/jp/avatar.rst --- a/doc/jp/avatar.rst Tue Apr 14 20:36:24 2020 +0200 +++ b/doc/jp/avatar.rst Tue Apr 14 21:00:33 2020 +0200 @@ -22,6 +22,9 @@ will try to open the image in a browser (which may sometimes result in using the default image software of the platform). +When available, cached avatar is returned by defaut. If you want to ignore the cache, use +the ``--no-cache`` option (of course this can result in more network requests). + example ------- diff -r 1af840e84af7 -r 6cf4bd6972c2 doc/jp/identity.rst --- a/doc/jp/identity.rst Tue Apr 14 20:36:24 2020 +0200 +++ b/doc/jp/identity.rst Tue Apr 14 21:00:33 2020 +0200 @@ -9,8 +9,11 @@ === Retrieve informations about the identity behind an XMPP entity. You only have to specify -the jid of the entity, and you'll get (if set) his/her/its nickname and a link to the -cached avatar. +the jid of the entity, and you'll get (if set) his/her/its nickname and data about the +avatar. + +When available, cached values are returned by defaut. If you want to ignore the cache, use +the ``--no-cache`` option (of course this can result in more network requests). example -------- @@ -22,13 +25,12 @@ set === -Set identity data to the server, using various XMPP extensions. You set the data to change -using ``-f KEY VALUE, --field KEY VALUE``, where ``KEY`` can only be ``nick`` at the -moment. +Set identity data to the server, using various XMPP extensions. So far, you can only +change the nickname of an entity using ``-n, --nick`` or or more times example ------- -Set the nickname of default profile:: +Set 2 nicknames for default profile:: - $ jp identity set -f nick toto + $ jp identity set -n toto -n titi diff -r 1af840e84af7 -r 6cf4bd6972c2 sat/bridge/bridge_constructor/bridge_template.ini --- a/sat/bridge/bridge_constructor/bridge_template.ini Tue Apr 14 20:36:24 2020 +0200 +++ b/sat/bridge/bridge_constructor/bridge_template.ini Tue Apr 14 21:00:33 2020 +0200 @@ -272,6 +272,7 @@ doc_param_1=keys: list of keys to get doc_param_2=%(doc_profile)s doc_return=dictionary with jids as keys and dictionary of asked key as values + values are serialised if key doesn't exist for a jid, the resulting dictionary will not have it [profileCreate] @@ -367,6 +368,19 @@ doc=Tell if a profile is connected doc_param_0=%(doc_profile_key)s +[contactGet] +async= +type=method +category=core +sig_in=ss +sig_out=(a{ss}as) +param_1_default="@DEFAULT@" +doc=Return informations in roster about a contact +doc_param_1=%(doc_profile_key)s +doc_return=tuple with the following values: + - list of attributes as in [newContact] + - groups where the contact is + [getContacts] async= type=method @@ -711,7 +725,6 @@ doc_param_1=%(doc_profile_key)s [updateContact] -async= type=method category=core sig_in=ssass diff -r 1af840e84af7 -r 6cf4bd6972c2 sat/bridge/dbus_bridge.py --- a/sat/bridge/dbus_bridge.py Tue Apr 14 20:36:24 2020 +0200 +++ b/sat/bridge/dbus_bridge.py Tue Apr 14 21:00:33 2020 +0200 @@ -253,6 +253,12 @@ return self._callback("connect", str(profile_key), str(password), options, callback=callback, errback=errback) @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, + in_signature='ss', out_signature='(a{ss}as)', + async_callbacks=('callback', 'errback')) + def contactGet(self, arg_0, profile_key="@DEFAULT@", callback=None, errback=None): + return self._callback("contactGet", str(arg_0), str(profile_key), callback=callback, errback=errback) + + @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, in_signature='ss', out_signature='', async_callbacks=('callback', 'errback')) def delContact(self, entity_jid, profile_key="@DEFAULT@", callback=None, errback=None): @@ -596,9 +602,9 @@ @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, in_signature='ssass', out_signature='', - async_callbacks=('callback', 'errback')) - def updateContact(self, entity_jid, name, groups, profile_key="@DEFAULT@", callback=None, errback=None): - return self._callback("updateContact", str(entity_jid), str(name), groups, str(profile_key), callback=callback, errback=errback) + async_callbacks=None) + def updateContact(self, entity_jid, name, groups, profile_key="@DEFAULT@"): + return self._callback("updateContact", str(entity_jid), str(name), groups, str(profile_key)) def __attributes(self, in_sign): """Return arguments to user given a in_sign diff -r 1af840e84af7 -r 6cf4bd6972c2 sat/core/constants.py --- a/sat/core/constants.py Tue Apr 14 20:36:24 2020 +0200 +++ b/sat/core/constants.py Tue Apr 14 21:00:33 2020 +0200 @@ -379,7 +379,6 @@ IGNORE = "ignore" NO_LIMIT = -1 # used in bridge when a integer value is expected DEFAULT_MAX_AGE = 1209600 # default max age of cached files, in seconds - HASH_SHA1_EMPTY = "da39a3ee5e6b4b0d3255bfef95601890afd80709" STANZA_NAMES = ("iq", "message", "presence") # Stream Hooks diff -r 1af840e84af7 -r 6cf4bd6972c2 sat/core/sat_main.py --- a/sat/core/sat_main.py Tue Apr 14 20:36:24 2020 +0200 +++ b/sat/core/sat_main.py Tue Apr 14 21:00:33 2020 +0200 @@ -102,12 +102,7 @@ self.bridge.register_method("getFeatures", self.getFeatures) self.bridge.register_method("profileNameGet", self.memory.getProfileName) self.bridge.register_method("profilesListGet", self.memory.getProfilesList) - self.bridge.register_method( - "getEntityData", - lambda jid_, keys, profile: self.memory.getEntityData( - jid.JID(jid_), keys, profile - ), - ) + self.bridge.register_method("getEntityData", self.memory._getEntityData) self.bridge.register_method("getEntitiesData", self.memory._getEntitiesData) self.bridge.register_method("profileCreate", self.memory.createProfile) self.bridge.register_method("asyncDeleteProfile", self.memory.asyncDeleteProfile) @@ -118,6 +113,7 @@ self.bridge.register_method("profileSetDefault", self.memory.profileSetDefault) self.bridge.register_method("connect", self._connect) self.bridge.register_method("disconnect", self.disconnect) + self.bridge.register_method("contactGet", self._contactGet) self.bridge.register_method("getContacts", self.getContacts) self.bridge.register_method("getContactsFromGroup", self.getContactsFromGroup) self.bridge.register_method("getMainResource", self.memory._getMainResource) @@ -539,6 +535,19 @@ d_list.addCallback(buildFeatures, list(self.plugins.keys())) return d_list + def _contactGet(self, entity_jid_s, profile_key): + client = self.getClient(profile_key) + entity_jid = jid.JID(entity_jid_s) + return defer.ensureDeferred(self.getContact(client, entity_jid)) + + async def getContact(self, client, entity_jid): + # we want to be sure that roster has been received + await client.roster.got_roster + item = client.roster.getItem(entity_jid) + if item is None: + raise exceptions.NotFound(f"{entity_jid} is not in roster!") + return (client.roster.getAttributes(item), list(item.groups)) + def getContacts(self, profile_key): client = self.getClient(profile_key) @@ -708,7 +717,7 @@ for resource in resources: res_jid = copy.copy(bare_jid) res_jid.resource = resource - cache_data = self.memory.getEntityData(res_jid, profile_key=client.profile) + cache_data = self.memory.getEntityData(client, res_jid) res_data = { "resource": resource, } @@ -957,17 +966,17 @@ self.profiles[profile].presence.subscribe(to_jid) def _updateContact(self, to_jid_s, name, groups, profile_key): - return self.updateContact(jid.JID(to_jid_s), name, groups, profile_key) + client = self.getClient(profile_key) + return self.updateContact(client, jid.JID(to_jid_s), name, groups) - def updateContact(self, to_jid, name, groups, profile_key): + def updateContact(self, client, to_jid, name, groups): """update a contact in roster list""" - profile = self.memory.getProfileName(profile_key) - assert profile - groups = set(groups) roster_item = RosterItem(to_jid) - roster_item.name = name or None + roster_item.name = name or u'' roster_item.groups = set(groups) - return self.profiles[profile].roster.setItem(roster_item) + if not self.trigger.point("roster_update", client, roster_item): + return + return client.roster.setItem(roster_item) def _delContact(self, to_jid_s, profile_key): return self.delContact(jid.JID(to_jid_s), profile_key) diff -r 1af840e84af7 -r 6cf4bd6972c2 sat/core/xmpp.py --- a/sat/core/xmpp.py Tue Apr 14 20:36:24 2020 +0200 +++ b/sat/core/xmpp.py Tue Apr 14 21:00:33 2020 +0200 @@ -650,7 +650,7 @@ # we may have a groupchat message, we check if the we know this jid try: entity_type = self.host_app.memory.getEntityDatum( - data["to"], C.ENTITY_TYPE, self.profile + self, data["to"], C.ENTITY_TYPE ) # FIXME: should entity_type manage resources ? except (exceptions.UnknownEntityError, KeyError): @@ -1551,15 +1551,21 @@ class SatPresenceProtocol(xmppim.PresenceClientProtocol): + def __init__(self, host): xmppim.PresenceClientProtocol.__init__(self) self.host = host + @property + def client(self): + return self.parent + def send(self, obj): presence_d = defer.succeed(None) if not self.host.trigger.point("Presence send", self.parent, obj, presence_d): return presence_d.addCallback(lambda __: super(SatPresenceProtocol, self).send(obj)) + return presence_d def availableReceived(self, entity, show=None, statuses=None, priority=0): if not statuses: @@ -1604,7 +1610,7 @@ # there is no need to send an unavailable signal try: presence = self.host.memory.getEntityDatum( - entity, "presence", self.parent.profile + self.client, entity, "presence" ) except (KeyError, exceptions.UnknownEntityError): # the entity has not been seen yet in this session @@ -1657,7 +1663,7 @@ if not self.host.trigger.point("presence_available", presence_elt, self.parent): return - self.send(presence_elt) + return self.send(presence_elt) @defer.inlineCallbacks def subscribed(self, entity): diff -r 1af840e84af7 -r 6cf4bd6972c2 sat/memory/disco.py --- a/sat/memory/disco.py Tue Apr 14 20:36:24 2020 +0200 +++ b/sat/memory/disco.py Tue Apr 14 21:00:33 2020 +0200 @@ -177,7 +177,7 @@ # we ignore cache, so we pretend we haven't found it raise KeyError cap_hash = self.host.memory.getEntityData( - jid_, [C.ENTITY_CAP_HASH], client.profile + client, jid_, [C.ENTITY_CAP_HASH] )[C.ENTITY_CAP_HASH] except (KeyError, exceptions.UnknownEntityError): # capability hash is not available, we'll compute one @@ -191,7 +191,7 @@ ext_form.typeCheck() self.hashes[cap_hash] = disco_infos self.host.memory.updateEntityData( - jid_, C.ENTITY_CAP_HASH, cap_hash, profile_key=client.profile + client, jid_, C.ENTITY_CAP_HASH, cap_hash ) return disco_infos @@ -214,7 +214,7 @@ # XXX we set empty disco in cache, to avoid getting an error or waiting # for a timeout again the next time self.host.memory.updateEntityData( - jid_, C.ENTITY_CAP_HASH, CAP_HASH_ERROR, profile_key=client.profile + client, jid_, C.ENTITY_CAP_HASH, CAP_HASH_ERROR ) raise fail @@ -243,7 +243,7 @@ # we cache items only for our own server and if node is not set try: items = self.host.memory.getEntityData( - jid_, ["DISCO_ITEMS"], client.profile + client, jid_, ["DISCO_ITEMS"] )["DISCO_ITEMS"] log.debug("[%s] disco items are in cache" % jid_.full()) if not use_cache: @@ -253,7 +253,7 @@ log.debug("Caching [%s] disco items" % jid_.full()) items = yield client.disco.requestItems(jid_, nodeIdentifier=node) self.host.memory.updateEntityData( - jid_, "DISCO_ITEMS", items, profile_key=client.profile + client, jid_, "DISCO_ITEMS", items ) else: try: diff -r 1af840e84af7 -r 6cf4bd6972c2 sat/memory/memory.py --- a/sat/memory/memory.py Tue Apr 14 20:36:24 2020 +0200 +++ b/sat/memory/memory.py Tue Apr 14 21:00:33 2020 +0200 @@ -649,7 +649,7 @@ full_jid = copy.copy(entity_jid) full_jid.resource = resource try: - presence_data = self.getEntityDatum(full_jid, "presence", profile_key) + presence_data = self.getEntityDatum(client, full_jid, "presence") except KeyError: continue entities_presence.setdefault(entity_jid, {})[ @@ -667,14 +667,15 @@ @param statuses: dictionary of statuses @param profile_key: %(doc_profile_key)s """ + client = self.host.getClient(profile_key) presence_data = PresenceTuple(show, priority, statuses) self.updateEntityData( - entity_jid, "presence", presence_data, profile_key=profile_key + client, entity_jid, "presence", presence_data ) if entity_jid.resource and show != C.PRESENCE_UNAVAILABLE: # If a resource is available, bare jid should not have presence information try: - self.delEntityDatum(entity_jid.userhostJID(), "presence", profile_key) + self.delEntityDatum(client, entity_jid.userhostJID(), "presence") except (KeyError, exceptions.UnknownEntityError): pass @@ -724,7 +725,7 @@ full_jid = copy.copy(entity_jid) full_jid.resource = resource try: - presence_data = self.getEntityDatum(full_jid, "presence", client.profile) + presence_data = self.getEntityDatum(client, full_jid, "presence") except KeyError: log.debug("Can't get presence data for {}".format(full_jid)) else: @@ -762,7 +763,7 @@ full_jid = copy.copy(entity_jid) full_jid.resource = resource try: - presence_data = self.getEntityDatum(full_jid, "presence", client.profile) + presence_data = self.getEntityDatum(client, full_jid, "presence") except KeyError: log.debug("No presence information for {}".format(full_jid)) continue @@ -812,7 +813,7 @@ yield full_jid def updateEntityData( - self, entity_jid, key, value, silent=False, profile_key=C.PROF_KEY_NONE + self, client, entity_jid, key, value, silent=False ): """Set a misc data for an entity @@ -823,9 +824,7 @@ @param value: value for this key (eg: C.ENTITY_TYPE_MUC) @param silent(bool): if True, doesn't send signal to frontend, even if there is a signal flag (see setSignalOnUpdate) - @param profile_key: %(doc_profile_key)s """ - client = self.host.getClient(profile_key) profile_cache = self._getProfileCache(client) if entity_jid in (C.ENTITY_ALL_RESOURCES, C.ENTITY_ALL): entities = self.getAllEntitiesIter(client, entity_jid == C.ENTITY_ALL) @@ -839,29 +838,23 @@ entity_data[key] = value if key in self._key_signals and not silent: - if not isinstance(value, str): - log.error( - "Setting a non string value ({}) for a key ({}) which has a signal flag".format( - value, key - ) - ) - else: - self.host.bridge.entityDataUpdated( - jid_.full(), key, value, self.getProfileName(profile_key) - ) + self.host.bridge.entityDataUpdated( + jid_.full(), + key, + data_format.serialise(value), + client.profile + ) - def delEntityDatum(self, entity_jid, key, profile_key): + def delEntityDatum(self, client, entity_jid, key): """Delete a data for an entity @param entity_jid: JID of the entity, C.ENTITY_ALL_RESOURCES for all resources of all entities, C.ENTITY_ALL for all entities (all resources + bare jids) @param key: key to delete (eg: C.ENTITY_TYPE) - @param profile_key: %(doc_profile_key)s @raise exceptions.UnknownEntityError: if entity is not in cache @raise KeyError: key is not in cache """ - client = self.host.getClient(profile_key) profile_cache = self._getProfileCache(client) if entity_jid in (C.ENTITY_ALL_RESOURCES, C.ENTITY_ALL): entities = self.getAllEntitiesIter(client, entity_jid == C.ENTITY_ALL) @@ -884,12 +877,16 @@ raise e def _getEntitiesData(self, entities_jids, keys_list, profile_key): + client = self.host.getClient(profile_key) ret = self.getEntitiesData( - [jid.JID(jid_) for jid_ in entities_jids], keys_list, profile_key + client, [jid.JID(jid_) for jid_ in entities_jids], keys_list ) - return {jid_.full(): data for jid_, data in ret.items()} + return { + jid_.full(): {k: data_format.serialise(v) for k,v in data.items()} + for jid_, data in ret.items() + } - def getEntitiesData(self, entities_jids, keys_list=None, profile_key=C.PROF_KEY_NONE): + def getEntitiesData(self, client, entities_jids, keys_list=None): """Get a list of cached values for several entities at once @param entities_jids: jids of the entities, or empty list for all entities in cache @@ -916,7 +913,6 @@ continue return entity_data - client = self.host.getClient(profile_key) profile_cache = self._getProfileCache(client) ret_data = {} if entities_jids: @@ -937,7 +933,11 @@ return ret_data - def getEntityData(self, entity_jid, keys_list=None, profile_key=C.PROF_KEY_NONE): + def _getEntityData(self, entity_jid_s, keys_list=None, profile=C.PROF_KEY_NONE): + return self.getEntityData( + self.host.getClient(profile), jid.JID(entity_jid_s), keys_list) + + def getEntityData(self, client, entity_jid, keys_list=None): """Get a list of cached values for entity @param entity_jid: JID of the entity @@ -949,7 +949,6 @@ @raise exceptions.UnknownEntityError: if entity is not in cache """ - client = self.host.getClient(profile_key) profile_cache = self._getProfileCache(client) try: entity_data = profile_cache[entity_jid.userhostJID()][entity_jid.resource] @@ -964,18 +963,17 @@ return {key: entity_data[key] for key in keys_list if key in entity_data} - def getEntityDatum(self, entity_jid, key, profile_key): + def getEntityDatum(self, client, entity_jid, key): """Get a datum from entity @param entity_jid: JID of the entity - @param keys: key to get - @param profile_key: %(doc_profile_key)s + @param key: key to get @return: requested value @raise exceptions.UnknownEntityError: if entity is not in cache @raise KeyError: if there is no value for this key and this entity """ - return self.getEntityData(entity_jid, (key,), profile_key)[key] + return self.getEntityData(client, entity_jid, (key,))[key] def delEntityCache( self, entity_jid, delete_all_resources=True, profile_key=C.PROF_KEY_NONE @@ -1595,7 +1593,7 @@ self.getAvailableResources(client, entity_jid) ) # is any resource is available, entity is available try: - presence_data = self.getEntityDatum(entity_jid, "presence", client.profile) + presence_data = self.getEntityDatum(client, entity_jid, "presence") except KeyError: log.debug("No presence information for {}".format(entity_jid)) return False diff -r 1af840e84af7 -r 6cf4bd6972c2 sat/plugins/plugin_exp_parrot.py --- a/sat/plugins/plugin_exp_parrot.py Tue Apr 14 20:36:24 2020 +0200 +++ b/sat/plugins/plugin_exp_parrot.py Tue Apr 14 21:00:33 2020 +0200 @@ -76,8 +76,6 @@ def messageReceivedTrigger(self, client, message_elt, post_treat): """ Check if source is linked and repeat message, else do nothing """ # TODO: many things are not repeated (subject, thread, etc) - profile = client.profile - client = self.host.getClient(profile) from_jid = message_elt["from"] try: @@ -95,7 +93,7 @@ try: entity_type = self.host.memory.getEntityData( - from_jid, [C.ENTITY_TYPE], profile)[C.ENTITY_TYPE] + client, from_jid, [C.ENTITY_TYPE])[C.ENTITY_TYPE] except (UnknownEntityError, KeyError): entity_type = "contact" if entity_type == C.ENTITY_TYPE_MUC: diff -r 1af840e84af7 -r 6cf4bd6972c2 sat/plugins/plugin_exp_pubsub_schema.py --- a/sat/plugins/plugin_exp_pubsub_schema.py Tue Apr 14 20:36:24 2020 +0200 +++ b/sat/plugins/plugin_exp_pubsub_schema.py Tue Apr 14 21:00:33 2020 +0200 @@ -650,8 +650,8 @@ values["updated"] = now if fill_author: if not values.get("author"): - identity = yield self._i.getIdentity(client, client.jid) - values["author"] = identity["nick"] + id_data = yield self._i.getIdentity(client, None, ["nicknames"]) + values["author"] = id_data['nicknames'][0] if not values.get("author_jid"): values["author_jid"] = client.jid.full() item_id = yield self.sendDataFormItem( diff -r 1af840e84af7 -r 6cf4bd6972c2 sat/plugins/plugin_misc_identity.py --- a/sat/plugins/plugin_misc_identity.py Tue Apr 14 20:36:24 2020 +0200 +++ b/sat/plugins/plugin_misc_identity.py Tue Apr 14 21:00:33 2020 +0200 @@ -1,9 +1,6 @@ #!/usr/bin/env python3 - -# SAT plugin for managing xep-0054 # Copyright (C) 2009-2020 Jérôme Poisson (goffi@goffi.org) -# Copyright (C) 2014 Emmanuel Gil Peyrot (linkmauve@linkmauve.fr) # 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 @@ -18,15 +15,21 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +from collections import namedtuple +from pathlib import Path +from twisted.internet import defer +from twisted.words.protocols.jabber import jid from sat.core.i18n import _ from sat.core.constants import Const as C from sat.core import exceptions from sat.core.log import getLogger +from sat.memory import persistent +from sat.tools import image +from sat.tools import utils +from sat.tools.common import data_format + log = getLogger(__name__) -from twisted.internet import defer -from twisted.words.protocols.jabber import jid -import os.path PLUGIN_INFO = { @@ -34,95 +37,529 @@ C.PI_IMPORT_NAME: "IDENTITY", C.PI_TYPE: C.PLUG_TYPE_MISC, C.PI_PROTOCOLS: [], - C.PI_DEPENDENCIES: ["XEP-0054"], - C.PI_RECOMMENDATIONS: [], + C.PI_DEPENDENCIES: [], + C.PI_RECOMMENDATIONS: ["XEP-0045"], C.PI_MAIN: "Identity", C.PI_HANDLER: "no", C.PI_DESCRIPTION: _("""Identity manager"""), } +Callback = namedtuple("Callback", ("get", "set", "priority")) -class Identity(object): + +class Identity: + def __init__(self, host): log.info(_("Plugin Identity initialization")) self.host = host - self._v = host.plugins["XEP-0054"] + self._m = host.plugins.get("XEP-0045") + self.metadata = { + "avatar": { + "type": dict, + # convert avatar path to avatar metadata (and check validity) + "set_data_filter": self.avatarSetDataFilter, + # update profile avatar, so all frontends are aware + "set_post_treatment": self.avatarSetPostTreatment, + "update_is_new_data": self.avatarUpdateIsNewData, + "update_data_filter": self.avatarUpdateDataFilter, + # we store the metadata in database, to restore it on next connection + # (it is stored only for roster entities) + "store": True, + }, + "nicknames": { + "type": list, + # accumulate all nicknames from all callbacks in a list instead + # of returning only the data from the first successful callback + "get_all": True, + # append nicknames from roster, resource, etc. + "get_post_treatment": self.nicknamesGetPostTreatment, + "update_is_new_data": self.nicknamesUpdateIsNewData, + "store": True, + }, + } + host.trigger.add("roster_update", self._rosterUpdateTrigger) + host.memory.setSignalOnUpdate("avatar") + host.memory.setSignalOnUpdate("nicknames") host.bridge.addMethod( "identityGet", ".plugin", - in_sign="ss", - out_sign="a{ss}", + in_sign="sasbs", + out_sign="s", method=self._getIdentity, async_=True, ) host.bridge.addMethod( "identitySet", ".plugin", - in_sign="a{ss}s", + in_sign="ss", out_sign="", method=self._setIdentity, async_=True, ) + host.bridge.addMethod( + "avatarGet", + ".plugin", + in_sign="sbs", + out_sign="s", + method=self._getAvatar, + async_=True, + ) + host.bridge.addMethod( + "avatarSet", + ".plugin", + in_sign="sss", + out_sign="", + method=self._setAvatar, + async_=True, + ) - def _getIdentity(self, jid_str, profile): - jid_ = jid.JID(jid_str) + async def profileConnecting(self, client): + # we restore known identities from database + client._identity_storage = persistent.LazyPersistentBinaryDict( + "identity", client.profile) + + stored_data = await client._identity_storage.all() + + self.host.memory.storage.getPrivates( + namespace="identity", binary=True, profile=client.profile) + + to_delete = [] + + for key, value in stored_data.items(): + entity_s, name = key.split('\n') + if name not in self.metadata.keys(): + log.debug(f"removing {key} from storage: not an allowed metadata name") + to_delete.append(key) + continue + entity = jid.JID(entity_s) + + if name == 'avatar': + if value is not None: + try: + cache_uid = value['cache_uid'] + if not cache_uid: + raise ValueError + except (ValueError, KeyError): + log.warning( + f"invalid data for {entity} avatar, it will be deleted: " + f"{value}") + to_delete.append(key) + continue + cache = self.host.common_cache.getMetadata(cache_uid) + if cache is None: + log.debug( + f"purging avatar for {entity}: it is not in cache anymore") + to_delete.append(key) + continue + + self.host.memory.updateEntityData( + client, entity, name, value, silent=True + ) + + for key in to_delete: + await client._identity_storage.adel(key) + + def _rosterUpdateTrigger(self, client, roster_item): + old_item = client.roster.getItem(roster_item.jid) + if old_item is None or old_item.name != roster_item.name: + log.debug( + f"roster nickname has been updated to {roster_item.name!r} for " + f"{roster_item.jid}" + ) + defer.ensureDeferred( + self.update( + client, + "nicknames", + [roster_item.name], + roster_item.jid + ) + ) + return True + + def register(self, metadata_name, cb_get, cb_set, priority=0): + """Register callbacks to handle identity metadata + + @param metadata_name(str): name of metadata can be: + - avatar + - nicknames + @param cb_get(coroutine, Deferred): method to retrieve a metadata + the method will get client and metadata names to retrieve as arguments. + @param cb_set(coroutine, Deferred): method to set a metadata + the method will get client, metadata name to set, and value as argument. + @param priority(int): priority of this method for the given metadata. + methods with bigger priorities will be called first + """ + if not metadata_name in self.metadata.keys(): + raise ValueError(f"Invalid metadata_name: {metadata_name!r}") + callback = Callback(get=cb_get, set=cb_set, priority=priority) + cb_list = self.metadata[metadata_name].setdefault('callbacks', []) + cb_list.append(callback) + cb_list.sort(key=lambda c: c.priority, reverse=True) + + def getIdentityJid(self, client, peer_jid): + """Return jid to use to set identity metadata + + if it's a jid of a room occupant, full jid will be used + otherwise bare jid will be used + if None, bare jid of profile will be used + @return (jid.JID): jid to use for avatar + """ + if peer_jid is None: + return client.jid.userhostJID() + if self._m is None: + return peer_jid.userhostJID() + else: + return self._m.getBareOrFull(client, peer_jid) + + def checkType(self, metadata_name, value): + """Check that type used for a metadata is the one declared in self.metadata""" + value_type = self.metadata[metadata_name]["type"] + if not isinstance(value, value_type): + raise ValueError( + f"{value} has wrong type: it is {type(value)} while {value_type} was " + f"expected") + + async def get(self, client, metadata_name, entity, use_cache=True): + """Retrieve identity metadata of an entity + + if metadata is already in cache, it is returned. Otherwise, registered callbacks + will be tried in priority order (bigger to lower) + @param metadata_name(str): name of the metadata + must be one of self.metadata key + the name will also be used as entity data name in host.memory + @param entity(jid.JID, None): entity for which avatar is requested + None to use profile's jid + @param use_cache(bool): if False, cache won't be checked + """ + entity = self.getIdentityJid(client, entity) + try: + metadata = self.metadata[metadata_name] + except KeyError: + raise ValueError(f"Invalid metadata name: {metadata_name!r}") + get_all = metadata.get('get_all', False) + if use_cache: + try: + data = self.host.memory.getEntityDatum( + client, entity, metadata_name) + except (KeyError, exceptions.UnknownEntityError): + pass + else: + return data + + try: + callbacks = metadata['callbacks'] + except KeyError: + log.warning(_("No callback registered for {metadata_name}") + .format(metadata_name=metadata_name)) + return [] if get_all else None + + if get_all: + all_data = [] + + for callback in callbacks: + try: + data = await defer.ensureDeferred(callback.get(client, entity)) + except exceptions.CancelError: + continue + except Exception as e: + log.warning( + _("Error while trying to get {metadata_name} with {callback}: {e}") + .format(callback=callback.get, metadata_name=metadata_name, e=e)) + else: + if data: + self.checkType(metadata_name, data) + if get_all: + all_data.extend(data) + else: + break + else: + data = None + + if get_all: + data = all_data + + post_treatment = metadata.get("get_post_treatment") + if post_treatment is not None: + data = await utils.asDeferred(post_treatment, client, entity, data) + + self.host.memory.updateEntityData( + client, entity, metadata_name, data) + + if metadata.get('store', False): + key = f"{entity}\n{metadata_name}" + await client._identity_storage.aset(key, data) + + return data + + async def set(self, client, metadata_name, data, entity=None): + """Set identity metadata for an entity + + Registered callbacks will be tried in priority order (bigger to lower) + @param metadata_name(str): name of the metadata + must be one of self.metadata key + the name will also be used to set entity data in host.memory + @param data(object): value to set + @param entity(jid.JID, None): entity for which avatar is requested + None to use profile's jid + """ + entity = self.getIdentityJid(client, entity) + metadata = self.metadata[metadata_name] + data_filter = metadata.get("set_data_filter") + if data_filter is not None: + data = await utils.asDeferred(data_filter, client, entity, data) + self.checkType(metadata_name, data) + + try: + callbacks = metadata['callbacks'] + except KeyError: + log.warning(_("No callback registered for {metadata_name}") + .format(metadata_name=metadata_name)) + return exceptions.FeatureNotFound(f"Can't set {metadata_name} for {entity}") + + for callback in callbacks: + try: + await defer.ensureDeferred(callback.set(client, data, entity)) + except exceptions.CancelError: + continue + except Exception as e: + log.warning( + _("Error while trying to set {metadata_name} with {callback}: {e}") + .format(callback=callback.set, metadata_name=metadata_name, e=e)) + else: + break + else: + raise exceptions.FeatureNotFound(f"Can't set {metadata_name} for {entity}") + + post_treatment = metadata.get("set_post_treatment") + if post_treatment is not None: + await utils.asDeferred(post_treatment, client, entity, data) + + async def update(self, client, metadata_name, data, entity): + """Update a metadata in cache + + This method may be called by plugins when an identity metadata is available. + """ + entity = self.getIdentityJid(client, entity) + metadata = self.metadata[metadata_name] + + try: + cached_data = self.host.memory.getEntityDatum( + client, entity, metadata_name) + except (KeyError, exceptions.UnknownEntityError): + # metadata is not cached, we do the update + pass + else: + # metadata is cached, we check if the new value differs from the cached one + try: + update_is_new_data = metadata["update_is_new_data"] + except KeyError: + update_is_new_data = self.defaultUpdateIsNewData + + if not update_is_new_data(client, entity, cached_data, data): + if cached_data is None: + log.debug( + f"{metadata_name} for {entity} is already disabled, nothing to " + f"do") + else: + log.debug( + f"{metadata_name} for {entity} is already in cache, nothing to " + f"do") + return + + # we can't use the cache, so we do the update + + log.debug(f"updating {metadata_name} for {entity}") + + if metadata.get('get_all', False): + # get_all is set, meaning that we have to check all plugins + # so we first delete current cache + self.host.memory.delEntityDatum(client, entity, metadata_name) + # then fill it again by calling get, which will retrieve all values + await self.get(client, metadata_name, entity) + return + + if data is not None: + data_filter = metadata['update_data_filter'] + if data_filter is not None: + data = await utils.asDeferred(data_filter, client, entity, data) + self.checkType(metadata_name, data) + + self.host.memory.updateEntityData(client, entity, metadata_name, data) + + if metadata.get('store', False): + key = f"{entity}\n{metadata_name}" + await client._identity_storage.aset(key, data) + + def defaultUpdateIsNewData(self, client, entity, cached_data, new_data): + return new_data != cached_data + + def _getAvatar(self, entity, use_cache, profile): client = self.host.getClient(profile) - return self.getIdentity(client, jid_) + entity = jid.JID(entity) if entity else None + d = defer.ensureDeferred(self.get(client, "avatar", entity, use_cache)) + d.addCallback(lambda data: data_format.serialise(data)) + return d + + def _setAvatar(self, file_path, entity, profile_key=C.PROF_KEY_NONE): + client = self.host.getClient(profile_key) + entity = jid.JID(entity) if entity else None + return defer.ensureDeferred( + self.set(client, "avatar", file_path, entity)) + + async def avatarSetDataFilter(self, client, entity, file_path): + """Convert avatar file path to dict data""" + file_path = Path(file_path) + if not file_path.is_file(): + raise ValueError(f"There is no file at {file_path} to use as avatar") + avatar_data = { + 'path': file_path, + 'media_type': image.guess_type(file_path), + } + media_type = avatar_data['media_type'] + if media_type is None: + raise ValueError(f"Can't identify type of image at {file_path}") + if not media_type.startswith('image/'): + raise ValueError(f"File at {file_path} doesn't appear to be an image") + return avatar_data + + async def avatarSetPostTreatment(self, client, entity, avatar_data): + """Update our own avatar""" + await self.update(client, "avatar", avatar_data, entity) + + def avatarBuildMetadata(self, path, media_type=None, cache_uid=None): + """Helper method to generate avatar metadata + + @param path(str, Path, None): path to avatar file + avatar file must be in cache + None if avatar is explicitely not set + @param media_type(str, None): type of the avatar file (MIME type) + @param cache_uid(str, None): UID of avatar in cache + @return (dict, None): avatar metadata + None if avatar is not set + """ + if path is None: + return None + else: + if cache_uid is None: + raise ValueError("cache_uid must be set if path is set") + path = Path(path) + if media_type is None: + media_type = image.guess_type(path) + + return { + "path": path, + "media_type": media_type, + "cache_uid": cache_uid, + } + + def avatarUpdateIsNewData(self, client, entity, cached_data, file_path): + if cached_data is None: + return file_path is not None - @defer.inlineCallbacks - def getIdentity(self, client, jid_): + if file_path is not None and file_path == cached_data['path']: + if file_path is None: + log.debug( + f"Avatar is already disabled for {entity}, nothing to do") + else: + log.debug( + f"Avatar at {file_path} is already used by {entity}, nothing " + f"to do") + return + + async def avatarUpdateDataFilter(self, client, entity, data): + if not isinstance(data, dict): + raise ValueError(f"Invalid data type ({type(data)}), a dict is expected") + mandatory_keys = {'path', 'cache_uid'} + if not data.keys() >= mandatory_keys: + raise ValueError(f"missing avatar data keys: {mandatory_keys - data.keys()}") + return data + + async def nicknamesGetPostTreatment(self, client, entity, plugin_nicknames): + """Prepend nicknames from core locations + set default nickname + + nicknames are checked from many locations, there is always at least + one nickname. First nickname of the list can be used in priority. + Nicknames are appended in this order: + - roster, plugins set nicknames + - if no nickname is found, user part of jid is then used, or bare jid + if there is no user part. + For MUC, room nick is always put first + """ + # we first check roster + nicknames = [] + if entity.resource: + # getIdentityJid let the resource only if the entity is a MUC room + # occupant jid + nicknames.append(entity.resource) + + roster_item = client.roster.getItem(entity.userhostJID()) + if roster_item is not None and roster_item.name: + # user set name has priority over entity set name + nicknames.append(roster_item.name) + + nicknames.extend(plugin_nicknames) + + if not nicknames: + if entity.user: + nicknames.append(entity.user.capitalize()) + else: + nicknames.append(entity.userhost()) + + # we remove duplicates while preserving order with dict + return list(dict.fromkeys(nicknames)) + + def nicknamesUpdateIsNewData(self, client, entity, cached_data, new_nicknames): + return not set(new_nicknames).issubset(cached_data) + + def _getIdentity(self, entity_s, metadata_filter, use_cache, profile): + entity = jid.JID(entity_s) + client = self.host.getClient(profile) + d = defer.ensureDeferred( + self.getIdentity(client, entity, metadata_filter, use_cache)) + d.addCallback(lambda data: data_format.serialise(data)) + return d + + async def getIdentity( + self, client, entity=None, metadata_filter=None, use_cache=True): """Retrieve identity of an entity - @param jid_(jid.JID): entity to check - @return (dict(unicode, unicode)): identity data where key can be: - - nick: nickname of the entity - nickname is checked from, in this order: - roster, vCard, user part of jid - cache is used when possible + @param entity(jid.JID, None): entity to check + @param metadata_filter(list[str], None): if not None or empty, only return + metadata in this filter + @param use_cache(bool): if False, cache won't be checked + should be True most of time, to avoid useless network requests + @return (dict): identity data """ id_data = {} - # we first check roster - roster_item = yield client.roster.getItem(jid_.userhostJID()) - if roster_item is not None and roster_item.name: - id_data["nick"] = roster_item.name - elif jid_.resource and self._v.isRoom(client, jid_): - id_data["nick"] = jid_.resource + + if not metadata_filter: + metadata_names = self.metadata.keys() else: - #  and finally then vcard - nick = yield self._v.getNick(client, jid_) - if nick: - id_data["nick"] = nick - elif jid_.user: - id_data["nick"] = jid_.user.capitalize() - else: - id_data["nick"] = jid_.userhost() + metadata_names = metadata_filter - try: - avatar_path = id_data["avatar"] = yield self._v.getAvatar( - client, jid_, cache_only=False - ) - except exceptions.NotFound: - pass - else: - if avatar_path: - id_data["avatar_basename"] = os.path.basename(avatar_path) - else: - del id_data["avatar"] + for metadata_name in metadata_names: + id_data[metadata_name] = await self.get( + client, metadata_name, entity, use_cache) + + return id_data - defer.returnValue(id_data) - - def _setIdentity(self, id_data, profile): + def _setIdentity(self, id_data_s, profile): client = self.host.getClient(profile) - return self.setIdentity(client, id_data) + id_data = data_format.deserialise(id_data_s) + return defer.ensureDeferred(self.setIdentity(client, id_data)) - def setIdentity(self, client, id_data): + async def setIdentity(self, client, id_data): """Update profile's identity - @param id_data(dict[unicode, unicode]): data to update, key can be: - - nick: nickname - the vCard will be updated + @param id_data(dict): data to update, key can be on of self.metadata keys """ - if list(id_data.keys()) != ["nick"]: - raise NotImplementedError("Only nick can be updated for now") - if "nick" in id_data: - return self._v.setNick(client, id_data["nick"]) + if not id_data.keys() <= self.metadata.keys(): + raise ValueError( + f"Invalid metadata names: {id_data.keys() - self.metadata.keys()}") + for metadata_name, data in id_data.items(): + try: + await self.set(client, metadata_name, data) + except Exception as e: + log.warning( + _("Can't set metadata {metadata_name!r}: {reason}") + .format(metadata_name=metadata_name, reason=e)) diff -r 1af840e84af7 -r 6cf4bd6972c2 sat/plugins/plugin_misc_watched.py --- a/sat/plugins/plugin_misc_watched.py Tue Apr 14 20:36:24 2020 +0200 +++ b/sat/plugins/plugin_misc_watched.py Tue Apr 14 21:00:33 2020 +0200 @@ -71,7 +71,7 @@ # we check that the previous presence was unavailable (no notification else) try: old_show = self.host.memory.getEntityDatum( - entity, "presence", client.profile).show + client, entity, "presence").show except (KeyError, exceptions.UnknownEntityError): old_show = C.PRESENCE_UNAVAILABLE diff -r 1af840e84af7 -r 6cf4bd6972c2 sat/plugins/plugin_xep_0045.py --- a/sat/plugins/plugin_xep_0045.py Tue Apr 14 20:36:24 2020 +0200 +++ b/sat/plugins/plugin_xep_0045.py Tue Apr 14 21:00:33 2020 +0200 @@ -235,6 +235,31 @@ else: return True + def isRoom(self, client, entity_jid): + """Tell if a jid is a joined MUC + + similar to isJoinedRoom but returns a boolean + @param entity_jid(jid.JID): full or bare jid of the entity check + @return (bool): True if the bare jid of the entity is a room jid + """ + try: + self.checkRoomJoined(client, entity_jid.userhostJID()) + except exceptions.NotFound: + return False + else: + return True + + def getBareOrFull(self, client, peer_jid): + """use full jid if peer_jid is an occupant of a room, bare jid else + + @param peer_jid(jid.JID): entity to test + @return (jid.JID): bare or full jid + """ + if peer_jid.resource: + if not self.isRoom(client, peer_jid): + return peer_jid.userhostJID() + return peer_jid + def _getRoomJoinedArgs(self, room, profile): return [ room.roomJID.userhost(), @@ -1148,8 +1173,8 @@ # we set type so we don't have to use a deferred # with disco to check entity type self.host.memory.updateEntityData( - room.roomJID, C.ENTITY_TYPE, C.ENTITY_TYPE_MUC, - profile_key=self.client.profile) + self.client, room.roomJID, C.ENTITY_TYPE, C.ENTITY_TYPE_MUC + ) elif room.state not in (ROOM_STATE_OCCUPANTS, ROOM_STATE_LIVE): log.warning( "Received user presence data in a room before its initialisation " diff -r 1af840e84af7 -r 6cf4bd6972c2 sat/plugins/plugin_xep_0054.py --- a/sat/plugins/plugin_xep_0054.py Tue Apr 14 20:36:24 2020 +0200 +++ b/sat/plugins/plugin_xep_0054.py Tue Apr 14 21:00:33 2020 +0200 @@ -1,6 +1,5 @@ #!/usr/bin/env python3 - # SAT plugin for managing xep-0054 # Copyright (C) 2009-2020 Jérôme Poisson (goffi@goffi.org) # Copyright (C) 2014 Emmanuel Gil Peyrot (linkmauve@linkmauve.fr) @@ -18,9 +17,10 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -import mimetypes +import io from base64 import b64decode, b64encode from hashlib import sha1 +from pathlib import Path from zope.interface import implementer from twisted.internet import threads, defer from twisted.words.protocols.jabber import jid, error @@ -32,6 +32,7 @@ from sat.core.constants import Const as C from sat.core.log import getLogger from sat.memory import persistent +from sat.tools import image log = getLogger(__name__) @@ -41,15 +42,26 @@ raise exceptions.MissingModule( "Missing module pillow, please download/install it from https://python-pillow.github.io" ) -import io try: from twisted.words.protocols.xmlstream import XMPPHandler except ImportError: from wokkel.subprotocols import XMPPHandler + +PLUGIN_INFO = { + C.PI_NAME: "XEP 0054 Plugin", + C.PI_IMPORT_NAME: "XEP-0054", + C.PI_TYPE: "XEP", + C.PI_PROTOCOLS: ["XEP-0054", "XEP-0153"], + C.PI_DEPENDENCIES: ["IDENTITY"], + C.PI_RECOMMENDATIONS: [], + C.PI_MAIN: "XEP_0054", + C.PI_HANDLER: "yes", + C.PI_DESCRIPTION: _("""Implementation of vcard-temp"""), +} + AVATAR_PATH = "avatars" -# AVATAR_DIM = (64, 64) #  FIXME: dim are not adapted to modern resolutions ! AVATAR_DIM = (128, 128) IQ_GET = '/iq[@type="get"]' @@ -60,158 +72,39 @@ NS_VCARD_UPDATE = "vcard-temp:x:update" VCARD_UPDATE = PRESENCE + '/x[@xmlns="' + NS_VCARD_UPDATE + '"]' -CACHED_DATA = {"avatar", "nick"} -MAX_AGE = 60 * 60 * 24 * 365 - -PLUGIN_INFO = { - C.PI_NAME: "XEP 0054 Plugin", - C.PI_IMPORT_NAME: "XEP-0054", - C.PI_TYPE: "XEP", - C.PI_PROTOCOLS: ["XEP-0054", "XEP-0153"], - C.PI_DEPENDENCIES: [], - C.PI_RECOMMENDATIONS: ["XEP-0045"], - C.PI_MAIN: "XEP_0054", - C.PI_HANDLER: "yes", - C.PI_DESCRIPTION: _("""Implementation of vcard-temp"""), -} +HASH_SHA1_EMPTY = "da39a3ee5e6b4b0d3255bfef95601890afd80709" class XEP_0054(object): - # TODO: - check that nickname is ok - # - refactor the code/better use of Wokkel - # - get missing values def __init__(self, host): log.info(_("Plugin XEP_0054 initialization")) self.host = host - host.bridge.addMethod( - "avatarGet", - ".plugin", - in_sign="sbbs", - out_sign="s", - method=self._getAvatar, - async_=True, - ) - host.bridge.addMethod( - "avatarSet", - ".plugin", - in_sign="ss", - out_sign="", - method=self._setAvatar, - async_=True, - ) + self._i = host.plugins['IDENTITY'] + self._i.register('avatar', self.getAvatar, self.setAvatar) + self._i.register('nicknames', self.getNicknames, self.setNicknames) host.trigger.add("presence_available", self.presenceAvailableTrigger) - host.memory.setSignalOnUpdate("avatar") - host.memory.setSignalOnUpdate("nick") def getHandler(self, client): return XEP_0054_handler(self) - def isRoom(self, client, entity_jid): - """Tell if a jid is a MUC one - - @param entity_jid(jid.JID): full or bare jid of the entity check - @return (bool): True if the bare jid of the entity is a room jid - """ + def presenceAvailableTrigger(self, presence_elt, client): try: - muc_plg = self.host.plugins["XEP-0045"] + avatar_hash = client._xep_0054_avatar_hashes[client.jid.userhost()] except KeyError: - return False - - try: - muc_plg.checkRoomJoined(client, entity_jid.userhostJID()) - except exceptions.NotFound: - return False - else: + log.info( + _("No avatar in cache for {profile}") + .format(profile=client.profile)) return True - - def getBareOrFull(self, client, jid_): - """use full jid if jid_ is an occupant of a room, bare jid else - - @param jid_(jid.JID): entity to test - @return (jid.JID): bare or full jid - """ - if jid_.resource: - if not self.isRoom(client, jid_): - return jid_.userhostJID() - return jid_ - - def presenceAvailableTrigger(self, presence_elt, client): - if client.jid.userhost() in client._cache_0054: - try: - avatar_hash = client._cache_0054[client.jid.userhost()]["avatar"] - except KeyError: - log.info("No avatar in cache for {}".format(client.jid.userhost())) - return True - x_elt = domish.Element((NS_VCARD_UPDATE, "x")) - x_elt.addElement("photo", content=avatar_hash) - presence_elt.addChild(x_elt) + x_elt = domish.Element((NS_VCARD_UPDATE, "x")) + x_elt.addElement("photo", content=avatar_hash) + presence_elt.addChild(x_elt) return True - @defer.inlineCallbacks - def profileConnecting(self, client): - client._cache_0054 = persistent.PersistentBinaryDict(NS_VCARD, client.profile) - yield client._cache_0054.load() - self._fillCachedValues(client.profile) - - def _fillCachedValues(self, profile): - # FIXME: this may need to be reworked - # the current naive approach keeps a map between all jids - # in persistent cache, then put avatar hashs in memory. - # Hashes should be shared between profiles (or not ? what - # if the avatar is different depending on who is requesting it - # this is not possible with vcard-tmp, but it is with XEP-0084). - # Loading avatar on demand per jid may be an option to investigate. - client = self.host.getClient(profile) - for jid_s, data in client._cache_0054.items(): - jid_ = jid.JID(jid_s) - for name in CACHED_DATA: - try: - value = data[name] - if value is None: - log.error( - "{name} value for {jid_} is None, ignoring".format( - name=name, jid_=jid_ - ) - ) - continue - self.host.memory.updateEntityData( - jid_, name, data[name], silent=True, profile_key=profile - ) - except KeyError: - pass - - def updateCache(self, client, jid_, name, value): - """update cache value - - save value in memory in case of change - @param jid_(jid.JID): jid of the owner of the vcard - @param name(str): name of the item which changed - @param value(unicode, None): new value of the item - None to delete - """ - jid_ = self.getBareOrFull(client, jid_) - jid_s = jid_.full() - - if value is None: - try: - self.host.memory.delEntityDatum(jid_, name, client.profile) - except (KeyError, exceptions.UnknownEntityError): - pass - if name in CACHED_DATA: - try: - del client._cache_0054[jid_s][name] - except KeyError: - pass - else: - client._cache_0054.force(jid_s) - else: - self.host.memory.updateEntityData( - jid_, name, value, profile_key=client.profile - ) - if name in CACHED_DATA: - client._cache_0054.setdefault(jid_s, {})[name] = value - client._cache_0054.force(jid_s) + async def profileConnecting(self, client): + client._xep_0054_avatar_hashes = persistent.PersistentDict( + NS_VCARD, client.profile) + await client._xep_0054_avatar_hashes.load() def getCache(self, client, entity_jid, name): """return cached value for jid @@ -219,9 +112,9 @@ @param entity_jid(jid.JID): target contact @param name(unicode): name of the value ('nick' or 'avatar') @return(unicode, None): wanted value or None""" - entity_jid = self.getBareOrFull(client, entity_jid) + entity_jid = self._i.getIdentityJid(client, entity_jid) try: - data = self.host.memory.getEntityData(entity_jid, [name], client.profile) + data = self.host.memory.getEntityData(client, entity_jid, [name]) except exceptions.UnknownEntityError: return None return data.get(name) @@ -235,75 +128,66 @@ mime_type = None else: if not mime_type: - # MIME type not know, we'll only support PNG files - # TODO: autodetection using e.g. "magic" module - # (https://pypi.org/project/python-magic/) + # MIME type not known, we'll try autodetection below mime_type = None - elif mime_type not in ("image/gif", "image/jpeg", "image/png"): - if mime_type == "image/x-png": - # XXX: this old MIME type is still used by some clients - mime_type = "image/png" - else: - # TODO: handle other image formats (svg?) - log.warning( - "following avatar image format is not handled: {type} [{jid}]".format( - type=mime_type, jid=entity_jid.full() - ) - ) - raise Failure(exceptions.DataError()) + elif mime_type == "image/x-png": + # XXX: this old MIME type is still used by some clients + mime_type = "image/png" - ext = mimetypes.guess_extension(mime_type, strict=False) - assert ext is not None - if ext == ".jpe": - ext = ".jpg" - log.debug( - "photo of type {type} with extension {ext} found [{jid}]".format( - type=mime_type, ext=ext, jid=entity_jid.full() - ) - ) try: buf = str(next(photo_elt.elements(NS_VCARD, "BINVAL"))) except StopIteration: log.warning("BINVAL element not found") raise Failure(exceptions.NotFound()) + if not buf: log.warning("empty avatar for {jid}".format(jid=entity_jid.full())) raise Failure(exceptions.NotFound()) - if mime_type is None: - log.warning(_("no MIME type found for {entity}'s avatar, assuming image/png") - .format(entity=entity_jid.full())) - if buf[:8] != b'\x89\x50\x4e\x47\x0d\x0a\x1a\x0a': - log.warning("this is not a PNG file, ignoring it") - raise Failure(exceptions.DataError()) - else: - mime_type = "image/png" log.debug(_("Decoding binary")) decoded = b64decode(buf) del buf + + if mime_type is None: + log.debug( + f"no media type found specified for {entity_jid}'s avatar, trying to " + f"guess") + + try: + mime_type = image.guess_type(io.BytesIO(decoded)) + except IOError as e: + log.warning(f"Can't open avatar buffer: {e}") + + if mime_type is None: + msg = f"Can't find media type for {entity_jid}'s avatar" + log.warning(msg) + raise Failure(exceptions.DataError(msg)) + image_hash = sha1(decoded).hexdigest() - with client.cache.cacheData( + with self.host.common_cache.cacheData( PLUGIN_INFO["import_name"], image_hash, mime_type, - # we keep in cache for 1 year - MAX_AGE, ) as f: f.write(decoded) return image_hash - @defer.inlineCallbacks - def vCard2Dict(self, client, vcard, entity_jid): - """Convert a VCard to a dict, and save binaries""" - log.debug(("parsing vcard")) + async def vCard2Dict(self, client, vcard_elt, entity_jid): + """Convert a VCard_elt to a dict, and save binaries""" + log.debug(("parsing vcard_elt")) vcard_dict = {} - for elem in vcard.elements(): + for elem in vcard_elt.elements(): if elem.name == "FN": vcard_dict["fullname"] = str(elem) elif elem.name == "NICKNAME": - vcard_dict["nick"] = str(elem) - self.updateCache(client, entity_jid, "nick", vcard_dict["nick"]) + nickname = vcard_dict["nickname"] = str(elem) + await self._i.update( + client, + "nicknames", + [nickname], + entity_jid + ) elif elem.name == "URL": vcard_dict["website"] = str(elem) elif elem.name == "EMAIL": @@ -313,193 +197,144 @@ elif elem.name == "PHOTO": # TODO: handle EXTVAL try: - avatar_hash = yield threads.deferToThread( + avatar_hash = await threads.deferToThread( self.savePhoto, client, elem, entity_jid ) except (exceptions.DataError, exceptions.NotFound): avatar_hash = "" vcard_dict["avatar"] = avatar_hash except Exception as e: - log.error("avatar saving error: {}".format(e)) + log.error(f"avatar saving error: {e}") avatar_hash = None else: vcard_dict["avatar"] = avatar_hash - self.updateCache(client, entity_jid, "avatar", avatar_hash) - else: - log.debug("FIXME: [{}] VCard tag is not managed yet".format(elem.name)) - - # if a data in cache doesn't exist anymore, we need to delete it - # so we check CACHED_DATA no gotten (i.e. not in vcard_dict keys) - # and we reset them - for datum in CACHED_DATA.difference(list(vcard_dict.keys())): - log.debug( - "reseting vcard datum [{datum}] for {entity}".format( - datum=datum, entity=entity_jid.full() - ) - ) - self.updateCache(client, entity_jid, datum, None) - - defer.returnValue(vcard_dict) - - def _vCardCb(self, vcard_elt, to_jid, client): - """Called after the first get IQ""" - log.debug(_("VCard found")) - iq_elt = vcard_elt.parent - try: - from_jid = jid.JID(iq_elt["from"]) - except KeyError: - from_jid = client.jid.userhostJID() - d = self.vCard2Dict(client, vcard_elt, from_jid) - return d - - def _vCardEb(self, failure_, to_jid, client): - """Called when something is wrong with registration""" - log.warning( - "Can't get vCard for {jid}: {failure}".format( - jid=to_jid.full, failure=failure_ - ) - ) - self.updateCache(client, to_jid, "avatar", None) + if avatar_hash is not None: + await client._xep_0054_avatar_hashes.aset( + entity_jid.full(), avatar_hash) - def _getVcardElt(self, iq_elt): - return next(iq_elt.elements(NS_VCARD, "vCard")) - - def getCardRaw(self, client, entity_jid): - """get raw vCard XML - - params are as in [getCard] - """ - entity_jid = self.getBareOrFull(client, entity_jid) - log.debug("Asking for {}'s VCard".format(entity_jid.full())) - reg_request = client.IQ("get") - reg_request["from"] = client.jid.full() - reg_request["to"] = entity_jid.full() - reg_request.addElement("vCard", NS_VCARD) - d = reg_request.send(entity_jid.full()) - d.addCallback(self._getVcardElt) - return d + if avatar_hash: + avatar_cache = self.host.common_cache.getMetadata(avatar_hash) + await self._i.update( + client, + "avatar", + { + 'path': avatar_cache['path'], + 'media_type': avatar_cache['mime_type'], + 'cache_uid': avatar_hash + }, + entity_jid + ) + else: + await self._i.update(client, "avatar", None, entity_jid) + else: + log.debug("FIXME: [{}] VCard_elt tag is not managed yet".format(elem.name)) - def getCard(self, client, entity_jid): - """Ask server for VCard - - @param entity_jid(jid.JID): jid from which we want the VCard - @result: id to retrieve the profile - """ - d = self.getCardRaw(client, entity_jid) - d.addCallbacks( - self._vCardCb, - self._vCardEb, - callbackArgs=[entity_jid, client], - errbackArgs=[entity_jid, client], - ) - return d - - def _getCardCb(self, __, client, entity): - try: - return client._cache_0054[entity.full()]["avatar"] - except KeyError: - raise Failure(exceptions.NotFound()) + return vcard_dict - def _getAvatar(self, entity, cache_only, hash_only, profile): - client = self.host.getClient(profile) - d = self.getAvatar(client, jid.JID(entity), cache_only, hash_only) - # we need to convert the Path to string - d.addCallback(str) - d.addErrback(lambda __: "") - return d - - def getAvatar(self, client, entity, cache_only=True, hash_only=False): - """get avatar full path or hash + async def getVCardElement(self, client, entity_jid): + """Retrieve domish.Element of a VCard - if avatar is not in local cache, it will be requested to the server - @param entity(jid.JID): entity to get avatar from - @param cache_only(bool): if False, will request vCard if avatar is - not in cache - @param hash_only(bool): if True only return hash, not full path - @raise exceptions.NotFound: no avatar found + @param entity_jid(jid.JID): entity from who we need the vCard + @raise DataError: we got an invalid answer """ - if not entity.resource and self.isRoom(client, entity): - raise exceptions.NotFound - entity = self.getBareOrFull(client, entity) - full_path = None - + iq_elt = client.IQ("get") + iq_elt["from"] = client.jid.full() + iq_elt["to"] = entity_jid.full() + iq_elt.addElement("vCard", NS_VCARD) + iq_ret_elt = await iq_elt.send(entity_jid.full()) try: - # we first check if we have avatar in cache - avatar_hash = client._cache_0054[entity.full()]["avatar"] - if avatar_hash: - # avatar is known and exists - full_path = client.cache.getFilePath(avatar_hash) - if full_path is None: - # cache file is not available (probably expired) - raise KeyError - else: - # avatar has already been checked but it is not set - full_path = "" - except KeyError: - # avatar is not in cache - if cache_only: - return defer.fail(Failure(exceptions.NotFound())) - # we request vCard to get avatar - d = self.getCard(client, entity) - d.addCallback(self._getCardCb, client, entity) - else: - # avatar is in cache, we can return hash - d = defer.succeed(avatar_hash) + return next(iq_ret_elt.elements(NS_VCARD, "vCard")) + except StopIteration: + log.warning(_( + "vCard element not found for {entity_jid}: {xml}" + ).format(entity_jid=entity_jid, xml=iq_ret_elt.toXml())) + raise exceptions.DataError(f"no vCard element found for {entity_jid}") - if not hash_only: - # full path is requested - if full_path is None: - d.addCallback(client.cache.getFilePath) - else: - d.addCallback(lambda __: full_path) - return d + async def updateVCardElt(self, client, entity_jid, to_replace): + """Create a vcard element to replace some metadata - @defer.inlineCallbacks - def getNick(self, client, entity): - """get nick from cache, or check vCard - - @param entity(jid.JID): entity to get nick from - @return(unicode, None): nick or None if not found + @param to_replace(list[str]): list of vcard element names to remove """ - nick = self.getCache(client, entity, "nick") - if nick is not None: - defer.returnValue(nick) - yield self.getCard(client, entity) - defer.returnValue(self.getCache(client, entity, "nick")) - - @defer.inlineCallbacks - def setNick(self, client, nick): - """update our vCard and set a nickname - - @param nick(unicode): new nickname to use - """ - jid_ = client.jid.userhostJID() try: - vcard_elt = yield self.getCardRaw(client, jid_) + # we first check if a vcard already exists, to keep data + vcard_elt = await self.getVCardElement(client, entity_jid) except error.StanzaError as e: if e.condition == "item-not-found": vcard_elt = domish.Element((NS_VCARD, "vCard")) else: raise e + except exceptions.DataError: + vcard_elt = domish.Element((NS_VCARD, "vCard")) + else: + # the vcard exists, we need to remove elements that we'll replace + for elt_name in to_replace: + try: + elt = next(vcard_elt.elements(NS_VCARD, elt_name)) + except StopIteration: + pass + else: + vcard_elt.children.remove(elt) + + return vcard_elt + + async def getCard(self, client, entity_jid): + """Ask server for VCard + + @param entity_jid(jid.JID): jid from which we want the VCard + @result(dict): vCard data + """ + entity_jid = self._i.getIdentityJid(client, entity_jid) + log.debug(f"Asking for {entity_jid}'s VCard") try: - nickname_elt = next(vcard_elt.elements(NS_VCARD, "NICKNAME")) - except StopIteration: - pass + vcard_elt = await self.getVCardElement(client, entity_jid) + except exceptions.DataError: + self._i.update(client, "avatar", None, entity_jid) + except Exception as e: + log.warning(_( + "Can't get vCard for {entity_jid}: {e}" + ).format(entity_jid=entity_jid, e=e)) else: - vcard_elt.children.remove(nickname_elt) + log.debug(_("VCard found")) + return await self.vCard2Dict(client, vcard_elt, entity_jid) - nickname_elt = vcard_elt.addElement((NS_VCARD, "NICKNAME"), content=nick) - iq_elt = client.IQ() - vcard_elt = iq_elt.addChild(vcard_elt) - yield iq_elt.send() - self.updateCache(client, jid_, "nick", str(nick)) + async def getAvatar(self, client, entity_jid): + """Get avatar full path or hash + + if avatar is not in local cache, it will be requested to the server + @param entity(jid.JID): entity to get avatar from - def _buildSetAvatar(self, client, vcard_elt, file_path): + """ + entity_jid = self._i.getIdentityJid(client, entity_jid) + hashes_cache = client._xep_0054_avatar_hashes + try: + avatar_hash = hashes_cache[entity_jid.full()] + except KeyError: + log.debug(f"avatar for {entity_jid} is not in cache, we retrieve it") + vcard = await self.getCard(client, entity_jid) + if vcard is None: + return None + avatar_hash = hashes_cache[entity_jid.full()] + + if not avatar_hash: + return None + + avatar_cache = self.host.common_cache.getMetadata(avatar_hash) + if avatar_cache is None: + log.debug("avatar is no more in cache, we re-download it") + vcard = await self.getCard(client, entity_jid) + if vcard is None: + return None + avatar_cache = self.host.common_cache.getMetadata(avatar_hash) + + return self._i.avatarBuildMetadata( + avatar_cache['path'], avatar_cache['mime_type'], avatar_hash) + + def _buildSetAvatar(self, client, vcard_elt, avatar_data): # XXX: this method is executed in a separate thread try: - img = Image.open(file_path) - except IOError: - return Failure(exceptions.DataError("Can't open image")) + img = Image.open(avatar_data['path']) + except IOError as e: + raise exceptions.DataError(f"Can't open image: {e}") if img.size != AVATAR_DIM: img.thumbnail(AVATAR_DIM) @@ -519,52 +354,65 @@ photo_elt = vcard_elt.addElement("PHOTO") photo_elt.addElement("TYPE", content="image/png") - image_b64 = b64encode(img_buf.getvalue()).decode('utf-8') + image_b64 = b64encode(img_buf.getvalue()).decode() photo_elt.addElement("BINVAL", content=image_b64) image_hash = sha1(img_buf.getvalue()).hexdigest() - with client.cache.cacheData( - PLUGIN_INFO["import_name"], image_hash, "image/png", MAX_AGE + with self.host.common_cache.cacheData( + PLUGIN_INFO["import_name"], image_hash, "image/png" ) as f: f.write(img_buf.getvalue()) + avatar_data['path'] = Path(f.name) + avatar_data['cache_uid'] = image_hash return image_hash - def _setAvatar(self, file_path, profile_key=C.PROF_KEY_NONE): - client = self.host.getClient(profile_key) - return self.setAvatar(client, file_path) - - @defer.inlineCallbacks - def setAvatar(self, client, file_path): + async def setAvatar(self, client, avatar_data, entity): """Set avatar of the profile - @param file_path: path of the image of the avatar + @param avatar_data(dict): data of the image to use as avatar, as built by + IDENTITY plugin. + @param entity(jid.JID): entity whose avatar must be changed """ - try: - # we first check if a vcard already exists, to keep data - vcard_elt = yield self.getCardRaw(client, client.jid.userhostJID()) - except error.StanzaError as e: - if e.condition == "item-not-found": - vcard_elt = domish.Element((NS_VCARD, "vCard")) - else: - raise e - else: - # the vcard exists, we need to remove PHOTO element as we'll make a new one - try: - photo_elt = next(vcard_elt.elements(NS_VCARD, "PHOTO")) - except StopIteration: - pass - else: - vcard_elt.children.remove(photo_elt) + vcard_elt = await self.updateVCardElt(client, entity, ['PHOTO']) iq_elt = client.IQ() iq_elt.addChild(vcard_elt) - image_hash = yield threads.deferToThread( - self._buildSetAvatar, client, vcard_elt, file_path + await threads.deferToThread( + self._buildSetAvatar, client, vcard_elt, avatar_data ) # image is now at the right size/format - self.updateCache(client, client.jid.userhostJID(), "avatar", image_hash) - yield iq_elt.send() - client.presence.available() # FIXME: should send the current presence, not always "available" ! + await iq_elt.send() + + # FIXME: should send the current presence, not always "available" ! + await client.presence.available() + + async def getNicknames(self, client, entity): + """get nick from cache, or check vCard + + @param entity(jid.JID): entity to get nick from + @return(list[str]): nicknames found + """ + vcard_data = await self.getCard(client, entity) + try: + return [vcard_data['nickname']] + except (KeyError, TypeError): + return [] + + async def setNicknames(self, client, nicknames, entity): + """Update our vCard and set a nickname + + @param nicknames(list[str]): new nicknames to use + only first item of this list will be used here + """ + nick = nicknames[0].strip() + + vcard_elt = await self.updateVCardElt(client, entity, ['NICKNAME']) + + if nick: + vcard_elt.addElement((NS_VCARD, "NICKNAME"), content=nick) + iq_elt = client.IQ() + iq_elt.addChild(vcard_elt) + await iq_elt.send() @implementer(iwokkel.IDisco) @@ -575,7 +423,7 @@ self.host = plugin_parent.host def connectionInitialized(self): - self.xmlstream.addObserver(VCARD_UPDATE, self.update) + self.xmlstream.addObserver(VCARD_UPDATE, self._update) def getDiscoInfo(self, requestor, target, nodeIdentifier=""): return [disco.DiscoFeature(NS_VCARD)] @@ -583,11 +431,11 @@ def getDiscoItems(self, requestor, target, nodeIdentifier=""): return [] - def _checkAvatarHash(self, __, client, entity, given_hash): - """check that hash in cash (i.e. computed hash) is the same as given one""" - # XXX: if they differ, the avater will be requested on each connection + def _checkAvatarHash(self, client, entity, given_hash): + """Check that hash in cache (i.e. computed hash) is the same as given one""" + # XXX: if they differ, the avatar will be requested on each connection # TODO: try to avoid re-requesting avatar in this case - computed_hash = self.plugin_parent.getCache(client, entity, "avatar") + computed_hash = client._xep_0054_avatar_hashes[entity.full] if computed_hash != given_hash: log.warning( "computed hash differs from given hash for {entity}:\n" @@ -596,15 +444,16 @@ ) ) - def update(self, presence): + async def update(self, presence): """Called on stanza with vcard data Check for avatar information, and get VCard if needed - @param presend(domish.Element): stanza + @param presence(domish.Element): stanza """ client = self.parent - entity_jid = self.plugin_parent.getBareOrFull(client, jid.JID(presence["from"])) - # FIXME: wokkel's data_form should be used here + entity_jid = self.plugin_parent._i.getIdentityJid( + client, jid.JID(presence["from"])) + try: x_elt = next(presence.elements(NS_VCARD_UPDATE, "x")) except StopIteration: @@ -615,45 +464,66 @@ except StopIteration: return - hash_ = str(photo_elt).strip() - if hash_ == C.HASH_SHA1_EMPTY: - hash_ = "" - old_avatar = self.plugin_parent.getCache(client, entity_jid, "avatar") + new_hash = str(photo_elt).strip() + if new_hash == HASH_SHA1_EMPTY: + new_hash = "" + + hashes_cache = client._xep_0054_avatar_hashes + + old_hash = hashes_cache.get(entity_jid.full()) - if old_avatar == hash_: - # no change, we can return... - if hash_: - # ...but we double check that avatar is in cache - file_path = client.cache.getFilePath(hash_) - if file_path is None: - log.error( - "Avatar for [{}] should be in cache but it is not! We get it".format( - entity_jid.full() - ) + if old_hash == new_hash: + # no change, we can return… + if new_hash: + # …but we double check that avatar is in cache + avatar_cache = self.host.common_cache.getMetadata(new_hash) + if avatar_cache is None: + log.debug( + f"Avatar for [{entity_jid}] is known but not in cache, we get " + f"it" ) - self.plugin_parent.getCard(client, entity_jid) - else: - log.debug("avatar for {} already in cache".format(entity_jid.full())) + # getCard will put the avatar in cache + await self.plugin_parent.getCard(client, entity_jid) + else: + log.debug(f"avatar for {entity_jid} is already in cache") return - if not hash_: - # the avatar has been removed - # XXX: we use empty string instead of None to indicate that we took avatar - # but it is empty on purpose - self.plugin_parent.updateCache(client, entity_jid, "avatar", "") + if new_hash is None: + # XXX: we use empty string to indicate that there is no avatar + new_hash = "" + + await hashes_cache.aset(entity_jid.full(), new_hash) + + if not new_hash: + await self.plugin_parent._i.update( + client, "avatar", None, entity_jid) + # the avatar has been removed, no need to go further return - file_path = client.cache.getFilePath(hash_) - if file_path is not None: + avatar_cache = self.host.common_cache.getMetadata(new_hash) + if avatar_cache is not None: log.debug( - "New avatar found for [{}], it's already in cache, we use it".format( - entity_jid.full() - ) + f"New avatar found for [{entity_jid}], it's already in cache, we use it" ) - self.plugin_parent.updateCache(client, entity_jid, "avatar", hash_) + await self.plugin_parent._i.update( + client, + "avatar", + { + 'path': avatar_cache['path'], + 'media_type': avatar_cache['mime_type'], + 'cache_uid': new_hash, + }, + entity_jid + ) else: log.debug( - "New avatar found for [{}], requesting vcard".format(entity_jid.full()) + "New avatar found for [{entity_jid}], requesting vcard" ) - d = self.plugin_parent.getCard(client, entity_jid) - d.addCallback(self._checkAvatarHash, client, entity_jid, hash_) + vcard = await self.plugin_parent.getCard(client, entity_jid) + if vcard is None: + log.warning(f"Unexpected empty vCard for {entity_jid}") + return + await self._checkAvatarHash(client, entity_jid, new_hash) + + def _update(self, presence): + defer.ensureDeferred(self.update(presence)) diff -r 1af840e84af7 -r 6cf4bd6972c2 sat/plugins/plugin_xep_0085.py --- a/sat/plugins/plugin_xep_0085.py Tue Apr 14 20:36:24 2020 +0200 +++ b/sat/plugins/plugin_xep_0085.py Tue Apr 14 21:00:33 2020 +0200 @@ -145,11 +145,12 @@ @param value: True, False or DELETE_VALUE to delete the entity data @param profile: current profile """ + client = self.host.getClient(profile) if value == DELETE_VALUE: - self.host.memory.delEntityDatum(entity_jid, ENTITY_KEY, profile) + self.host.memory.delEntityDatum(client, entity_jid, ENTITY_KEY) else: self.host.memory.updateEntityData( - entity_jid, ENTITY_KEY, value, profile_key=profile + client, entity_jid, ENTITY_KEY, value ) if not value or value == DELETE_VALUE: # reinit chat state UI for this or these contact(s) @@ -256,9 +257,10 @@ @param profile (str): %(doc_profile)s @return: bool """ + client = self.host.getClient(profile) try: type_ = self.host.memory.getEntityDatum( - to_jid.userhostJID(), C.ENTITY_TYPE, profile) + client, to_jid.userhostJID(), C.ENTITY_TYPE) if type_ == C.ENTITY_TYPE_MUC: return True except (exceptions.UnknownEntityError, KeyError): @@ -273,6 +275,7 @@ @param: current profile @return: True if the notifications should be sent to this JID. """ + client = self.host.getClient(profile) # check if the parameter is active if not self.host.memory.getParamA(PARAM_NAME, PARAM_KEY, profile_key=profile): return False @@ -282,7 +285,7 @@ # FIXME: this assertion crash when we want to send a message to an online bare jid # assert to_jid.resource or not self.host.memory.isEntityAvailable(to_jid, profile) # must either have a resource, or talk to an offline contact try: - return self.host.memory.getEntityDatum(to_jid, ENTITY_KEY, profile) + return self.host.memory.getEntityDatum(client, to_jid, ENTITY_KEY) except (exceptions.UnknownEntityError, KeyError): if forceEntityData: # enable it for the first time diff -r 1af840e84af7 -r 6cf4bd6972c2 sat/plugins/plugin_xep_0115.py --- a/sat/plugins/plugin_xep_0115.py Tue Apr 14 20:36:24 2020 +0200 +++ b/sat/plugins/plugin_xep_0115.py Tue Apr 14 21:00:33 2020 +0200 @@ -60,7 +60,7 @@ host.trigger.add("Presence send", self._presenceTrigger) def getHandler(self, client): - return XEP_0115_handler(self, client.profile) + return XEP_0115_handler(self) @defer.inlineCallbacks def _prepareCaps(self, client): @@ -97,7 +97,7 @@ if cap_hash not in self.host.memory.disco.hashes: self.host.memory.disco.hashes[cap_hash] = disco_infos self.host.memory.updateEntityData( - client.jid, C.ENTITY_CAP_HASH, cap_hash, profile_key=client.profile + client, client.jid, C.ENTITY_CAP_HASH, cap_hash ) def _presenceAddElt(self, client, obj): @@ -118,10 +118,13 @@ @implementer(iwokkel.IDisco) class XEP_0115_handler(XMPPHandler): - def __init__(self, plugin_parent, profile): + def __init__(self, plugin_parent): self.plugin_parent = plugin_parent self.host = plugin_parent.host - self.profile = profile + + @property + def client(self): + return self.parent def connectionInitialized(self): self.xmlstream.addObserver(CAPABILITY_UPDATE, self.update) @@ -158,7 +161,7 @@ % {"hash": c_ver, "jid": from_jid.full()} ) self.host.memory.updateEntityData( - from_jid, C.ENTITY_CAP_HASH, c_ver, profile_key=self.profile + self.client, from_jid, C.ENTITY_CAP_HASH, c_ver ) return @@ -173,7 +176,7 @@ def cb(__): computed_hash = self.host.memory.getEntityDatum( - from_jid, C.ENTITY_CAP_HASH, self.profile + self.client, from_jid, C.ENTITY_CAP_HASH ) if computed_hash != c_ver: log.warning( diff -r 1af840e84af7 -r 6cf4bd6972c2 sat/stdui/ui_contact_list.py --- a/sat/stdui/ui_contact_list.py Tue Apr 14 20:36:24 2020 +0200 +++ b/sat/stdui/ui_contact_list.py Tue Apr 14 21:00:33 2020 +0200 @@ -288,12 +288,13 @@ @param profile: %(doc_profile)s @return dict """ + client = self.host.getClient(profile) if C.bool(data.get("cancelled", "false")): return {} contact_jid = jid.JID(data[xml_tools.formEscape("contact_jid")]) # TODO: replace '\t' by a constant (see tools.xmlui.XMLUI.onFormSubmitted) groups = data[xml_tools.formEscape("groups_list")].split("\t") - self.host.updateContact(contact_jid, name="", groups=groups, profile_key=profile) + self.host.updateContact(client, contact_jid, name="", groups=groups) return {} def _deleteContact(self, contact_jid, profile): diff -r 1af840e84af7 -r 6cf4bd6972c2 sat_frontends/bridge/dbus_bridge.py --- a/sat_frontends/bridge/dbus_bridge.py Tue Apr 14 20:36:24 2020 +0200 +++ b/sat_frontends/bridge/dbus_bridge.py Tue Apr 14 21:00:33 2020 +0200 @@ -208,6 +208,15 @@ error_handler = lambda err:errback(dbus_to_bridge_exception(err)) return self.db_core_iface.connect(profile_key, password, options, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler) + def contactGet(self, arg_0, profile_key="@DEFAULT@", callback=None, errback=None): + if callback is None: + error_handler = None + else: + if errback is None: + errback = log.error + error_handler = lambda err:errback(dbus_to_bridge_exception(err)) + return self.db_core_iface.contactGet(arg_0, profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler) + def delContact(self, entity_jid, profile_key="@DEFAULT@", callback=None, errback=None): if callback is None: error_handler = None @@ -883,7 +892,12 @@ if errback is None: errback = log.error error_handler = lambda err:errback(dbus_to_bridge_exception(err)) - return self.db_core_iface.updateContact(entity_jid, name, groups, profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler) + kwargs={} + if callback is not None: + kwargs['timeout'] = const_TIMEOUT + kwargs['reply_handler'] = callback + kwargs['error_handler'] = error_handler + return self.db_core_iface.updateContact(entity_jid, name, groups, profile_key, **kwargs) class AIOBridge(Bridge): @@ -975,6 +989,14 @@ self.db_core_iface.connect(profile_key, password, options, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler) return fut + def contactGet(self, arg_0, profile_key="@DEFAULT@"): + loop = asyncio.get_running_loop() + fut = loop.create_future() + reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret) + error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err)) + self.db_core_iface.contactGet(arg_0, profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler) + return fut + def delContact(self, entity_jid, profile_key="@DEFAULT@"): loop = asyncio.get_running_loop() fut = loop.create_future() diff -r 1af840e84af7 -r 6cf4bd6972c2 sat_frontends/bridge/pb.py --- a/sat_frontends/bridge/pb.py Tue Apr 14 20:36:24 2020 +0200 +++ b/sat_frontends/bridge/pb.py Tue Apr 14 21:00:33 2020 +0200 @@ -1,7 +1,6 @@ #!/usr/bin/env python3 - -# SAT communication bridge +# SàT communication bridge # Copyright (C) 2009-2020 Jérôme Poisson (goffi@goffi.org) # This program is free software: you can redistribute it and/or modify @@ -206,6 +205,15 @@ else: d.addErrback(self._errback, ori_errback=errback) + def contactGet(self, arg_0, profile_key="@DEFAULT@", callback=None, errback=None): + d = self.root.callRemote("contactGet", arg_0, profile_key) + if callback is not None: + d.addCallback(callback) + if errback is None: + d.addErrback(self._generic_errback) + else: + d.addErrback(self._errback, ori_errback=errback) + def delContact(self, entity_jid, profile_key="@DEFAULT@", callback=None, errback=None): d = self.root.callRemote("delContact", entity_jid, profile_key) if callback is not None: @@ -788,6 +796,11 @@ d.addErrback(self._errback) return d.asFuture(asyncio.get_event_loop()) + def contactGet(self, arg_0, profile_key="@DEFAULT@"): + d = self.root.callRemote("contactGet", arg_0, profile_key) + d.addErrback(self._errback) + return d.asFuture(asyncio.get_event_loop()) + def delContact(self, entity_jid, profile_key="@DEFAULT@"): d = self.root.callRemote("delContact", entity_jid, profile_key) d.addErrback(self._errback) diff -r 1af840e84af7 -r 6cf4bd6972c2 sat_frontends/jp/cmd_avatar.py --- a/sat_frontends/jp/cmd_avatar.py Tue Apr 14 20:36:24 2020 +0200 +++ b/sat_frontends/jp/cmd_avatar.py Tue Apr 14 21:00:33 2020 +0200 @@ -25,10 +25,11 @@ from sat.core.i18n import _ from sat_frontends.jp.constants import Const as C from sat.tools import config +from sat.tools.common import data_format __commands__ = ["Avatar"] -DISPLAY_CMD = ["xv", "display", "gwenview", "showtell"] +DISPLAY_CMD = ["xdg-open", "xv", "display", "gwenview", "showtell"] class Get(base.CommandBase): @@ -38,10 +39,13 @@ ) def add_parser_options(self): - self.parser.add_argument("jid", help=_("entity")) + self.parser.add_argument( + "--no-cache", action="store_true", help=_("do no use cached values") + ) self.parser.add_argument( "-s", "--show", action="store_true", help=_("show avatar") ) + self.parser.add_argument("jid", nargs='?', default='', help=_("entity")) async def showImage(self, path): sat_conf = config.parseMainConf() @@ -68,20 +72,23 @@ async def start(self): try: - avatar_path = await self.host.bridge.avatarGet( + avatar_data_raw = await self.host.bridge.avatarGet( self.args.jid, - False, - False, + not self.args.no_cache, self.profile, ) except Exception as e: self.disp(f"can't retrieve avatar: {e}", error=True) self.host.quit(C.EXIT_BRIDGE_ERRBACK) - if not avatar_path: + avatar_data = data_format.deserialise(avatar_data_raw, type_check=None) + + if not avatar_data: self.disp(_("No avatar found."), 1) self.host.quit(C.EXIT_NOT_FOUND) + avatar_path = avatar_data['path'] + self.disp(avatar_path) if self.args.show: await self.showImage(avatar_path) @@ -92,11 +99,14 @@ class Set(base.CommandBase): def __init__(self, host): super(Set, self).__init__( - host, "set", use_verbose=True, help=_("set avatar of the profile") + host, "set", use_verbose=True, + help=_("set avatar of the profile or an entity") ) def add_parser_options(self): self.parser.add_argument( + "-j", "--jid", default='', help=_("entity whose avatar must be changed")) + self.parser.add_argument( "image_path", type=str, help=_("path to the image to upload") ) @@ -107,7 +117,7 @@ self.host.quit(C.EXIT_BAD_ARG) path = os.path.abspath(path) try: - await self.host.bridge.avatarSet(path, self.profile) + await self.host.bridge.avatarSet(path, self.args.jid, self.profile) except Exception as e: self.disp(f"can't set avatar: {e}", error=True) self.host.quit(C.EXIT_BRIDGE_ERRBACK) diff -r 1af840e84af7 -r 6cf4bd6972c2 sat_frontends/jp/cmd_event.py --- a/sat_frontends/jp/cmd_event.py Tue Apr 14 20:36:24 2020 +0200 +++ b/sat_frontends/jp/cmd_event.py Tue Apr 14 21:00:33 2020 +0200 @@ -18,14 +18,15 @@ # along with this program. If not, see . -from . import base +from dateutil import parser as du_parser +import calendar +import time from sat.core.i18n import _ from sat.tools.common.ansi import ANSI as A from sat_frontends.jp.constants import Const as C from sat_frontends.jp import common -from dateutil import parser as du_parser -import calendar -import time +from sat.tools.common import data_format +from . import base __commands__ = ["Event"] @@ -443,8 +444,9 @@ # we get nicknames for everybody, make it easier for organisers for jid_, data in prefilled.items(): - id_data = await self.host.bridge.identityGet(jid_, self.profile) - data["nick"] = id_data.get("nick", "") + id_data = await self.host.bridge.identityGet(jid_, [], True, self.profile) + id_data = data_format.deserialise(id_data) + data["nick"] = id_data['nicknames'][0] await self.output(prefilled) self.host.quit() diff -r 1af840e84af7 -r 6cf4bd6972c2 sat_frontends/jp/cmd_identity.py --- a/sat_frontends/jp/cmd_identity.py Tue Apr 14 20:36:24 2020 +0200 +++ b/sat_frontends/jp/cmd_identity.py Tue Apr 14 21:00:33 2020 +0200 @@ -21,6 +21,7 @@ from . import base from sat.core.i18n import _ from sat_frontends.jp.constants import Const as C +from sat.tools.common import data_format __commands__ = ["Identity"] @@ -38,6 +39,9 @@ def add_parser_options(self): self.parser.add_argument( + "--no-cache", action="store_true", help=_("do no use cached values") + ) + self.parser.add_argument( "jid", help=_("entity to check") ) @@ -46,37 +50,40 @@ try: data = await self.host.bridge.identityGet( jid_, - self.profile, + [], + not self.args.no_cache, + self.profile ) except Exception as e: self.disp(f"can't get identity data: {e}", error=True) self.host.quit(C.EXIT_BRIDGE_ERRBACK) else: + data = data_format.deserialise(data) await self.output(data) self.host.quit() class Set(base.CommandBase): def __init__(self, host): - super(Set, self).__init__(host, "set", help=_("modify an existing event")) + super(Set, self).__init__(host, "set", help=_("update identity data")) def add_parser_options(self): self.parser.add_argument( - "-f", - "--field", + "-n", + "--nickname", action="append", - nargs=2, - dest="fields", - metavar=("KEY", "VALUE"), + dest="nicknames", required=True, - help=_("identity field(s) to set"), + help=_("nicknames of the entity"), ) async def start(self): - fields = dict(self.args.fields) + id_data = { + "nicknames": self.args.nicknames, + } try: self.host.bridge.identitySet( - fields, + data_format.serialise(id_data), self.profile, ) except Exception as e: diff -r 1af840e84af7 -r 6cf4bd6972c2 sat_frontends/primitivus/contact_list.py --- a/sat_frontends/primitivus/contact_list.py Tue Apr 14 20:36:24 2020 +0200 +++ b/sat_frontends/primitivus/contact_list.py Tue Apr 14 21:00:33 2020 +0200 @@ -213,7 +213,7 @@ entity_txt = entity if with_show_attr: - show = self.contact_list.getCache(entity, C.PRESENCE_SHOW) + show = self.contact_list.getCache(entity, C.PRESENCE_SHOW, default=None) if show is None: show = C.PRESENCE_UNAVAILABLE show_icon, entity_attr = C.PRESENCE.get(show, ("", "default")) @@ -278,14 +278,14 @@ markup_extra.append(resource_disp) if self.contact_list.show_status: status = self.contact_list.getCache( - jid.JID("%s/%s" % (entity, resource)), "status" + jid.JID("%s/%s" % (entity, resource)), "status", default=None ) status_disp = ("status", "\n " + status) if status else "" markup_extra.append(status_disp) else: if self.contact_list.show_status: - status = self.contact_list.getCache(entity, "status") + status = self.contact_list.getCache(entity, "status", default=None) status_disp = ("status", "\n " + status) if status else "" markup_extra.append(status_disp) widget = self._buildEntityWidget( diff -r 1af840e84af7 -r 6cf4bd6972c2 sat_frontends/quick_frontend/constants.py --- a/sat_frontends/quick_frontend/constants.py Tue Apr 14 20:36:24 2020 +0200 +++ b/sat_frontends/quick_frontend/constants.py Tue Apr 14 21:00:33 2020 +0200 @@ -96,7 +96,7 @@ LISTENERS = { "avatar", - "nick", + "nicknames", "presence", "selected", "notification", diff -r 1af840e84af7 -r 6cf4bd6972c2 sat_frontends/quick_frontend/quick_app.py --- a/sat_frontends/quick_frontend/quick_app.py Tue Apr 14 20:36:24 2020 +0200 +++ b/sat_frontends/quick_frontend/quick_app.py Tue Apr 14 21:00:33 2020 +0200 @@ -44,7 +44,7 @@ # and a way to keep some XMLUI request between sessions is expected in backend host = None bridge = None - # cache_keys_to_get = ['avatar'] + cache_keys_to_get = ['avatar', 'nicknames'] def __init__(self, profile): self.profile = profile @@ -136,14 +136,10 @@ def _plug_profile_getFeaturesCb(self, features): self.host.features = features - # FIXME: we don't use cached value at the moment, but keep the code for later use - # it was previously used for avatars, but as we don't get full path here, - # it's better to request later - # self.host.bridge.getEntitiesData([], ProfileManager.cache_keys_to_get, - # profile=self.profile, - # callback=self._plug_profile_gotCachedValues, - # errback=self._plug_profile_failedCachedValues) - self._plug_profile_gotCachedValues({}) + self.host.bridge.getEntitiesData([], ProfileManager.cache_keys_to_get, + profile=self.profile, + callback=self._plug_profile_gotCachedValues, + errback=self._plug_profile_failedCachedValues) def _plug_profile_failedCachedValues(self, failure): log.error("Couldn't get cached values: {}".format(failure)) @@ -186,13 +182,6 @@ ) def _plug_profile_gotPresences(self, presences): - def gotEntityData(data, contact): - for key in ("avatar", "nick"): - if key in data: - self.host.entityDataUpdatedHandler( - contact, key, data[key], self.profile - ) - for contact in presences: for res in presences[contact]: jabber_id = ("%s/%s" % (jid.JID(contact).bare, res)) if res else contact @@ -202,15 +191,6 @@ self.host.presenceUpdateHandler( jabber_id, show, priority, statuses, self.profile ) - self.host.bridge.getEntityData( - contact, - ["avatar", "nick"], - self.profile, - callback=lambda data, contact=contact: gotEntityData(data, contact), - errback=lambda failure, contact=contact: log.debug( - "No cache data for {}".format(contact) - ), - ) # At this point, profile should be fully plugged # and we launch frontend specific method @@ -553,9 +533,9 @@ - contactsFilled: called when contact have been fully filled for a profiles kwargs: profile - avatar: called when avatar data is updated - args: (entity, avatar file, profile) - - nick: called when nick data is updated - args: (entity, new_nick, profile) + args: (entity, avatar_data, profile) + - nicknames: called when nicknames data is updated + args: (entity, nicknames, profile) - presence: called when a presence is received args: (entity, show, priority, statuses, profile) - selected: called when a widget is selected @@ -1261,15 +1241,18 @@ target = jid.JID(jid_s) self.contact_lists[profile].removeContact(target) - def entityDataUpdatedHandler(self, entity_s, key, value, profile): + def entityDataUpdatedHandler(self, entity_s, key, value_raw, profile): entity = jid.JID(entity_s) - if key == "nick": # this is the roster nick, not the MUC nick + value = data_format.deserialise(value_raw, type_check=None) + if key == "nicknames": + assert isinstance(value, list) or value is None if entity in self.contact_lists[profile]: - self.contact_lists[profile].setCache(entity, "nick", value) - self.callListeners("nick", entity, value, profile=profile) + self.contact_lists[profile].setCache(entity, "nicknames", value) + self.callListeners("nicknames", entity, value, profile=profile) elif key == "avatar" and self.AVATARS_HANDLER: - if value and entity in self.contact_lists[profile]: - self.getAvatar(entity, ignore_cache=True, profile=profile) + assert isinstance(value, dict) or value is None + self.contact_lists[profile].setCache(entity, "avatar", value) + self.callListeners("avatar", entity, value, profile=profile) def actionManager(self, action_data, callback=None, ui_show_cb=None, user_action=True, progress_cb=None, progress_eb=None, profile=C.PROF_KEY_NONE): @@ -1394,70 +1377,6 @@ errback=self.dialogFailure, ) - def _avatarGetCb(self, avatar_path, entity, contact_list, profile): - path = avatar_path or self.getDefaultAvatar(entity) - contact_list.setCache(entity, "avatar", path) - self.callListeners("avatar", entity, path, profile=profile) - - def _avatarGetEb(self, failure_, entity, contact_list): - # FIXME: bridge needs a proper error handling - if "NotFound" in str(failure_): - log.info("No avatar found for {entity}".format(entity=entity)) - else: - log.warning("Can't get avatar: {}".format(failure_)) - contact_list.setCache(entity, "avatar", self.getDefaultAvatar(entity)) - - def getAvatar( - self, - entity, - cache_only=True, - hash_only=False, - ignore_cache=False, - profile=C.PROF_KEY_NONE, - ): - """return avatar path for an entity - - @param entity(jid.JID): entity to get avatar from - @param cache_only(bool): if False avatar will be requested if not in cache - with current vCard based implementation, it's better to keep True - except if we request avatars for roster items - @param hash_only(bool): if True avatar hash is returned, else full path - @param ignore_cache(bool): if False, won't check local cache and will request - backend in every case - @return (unicode, None): avatar full path (None if no avatar found) - """ - contact_list = self.contact_lists[profile] - if ignore_cache: - avatar = None - else: - try: - avatar = contact_list.getCache(entity, "avatar", bare_default=None) - except exceptions.NotFound: - avatar = None - if avatar is None: - self.bridge.avatarGet( - str(entity), - cache_only, - hash_only, - profile=profile, - callback=lambda path: self._avatarGetCb( - path, entity, contact_list, profile - ), - errback=lambda failure: self._avatarGetEb(failure, entity, contact_list), - ) - # we set avatar to empty string to avoid requesting several time the same - # avatar while we are waiting for avatarGet result - contact_list.setCache(entity, "avatar", "") - return avatar - - def getDefaultAvatar(self, entity=None): - """return default avatar to use with given entity - - must be implemented by frontend - @param entity(jid.JID): entity for which a default avatar is needed - """ - raise NotImplementedError - def disconnect(self, profile): log.info("disconnecting") self.callListeners("disconnect", profile=profile) diff -r 1af840e84af7 -r 6cf4bd6972c2 sat_frontends/quick_frontend/quick_chat.py --- a/sat_frontends/quick_frontend/quick_chat.py Tue Apr 14 20:36:24 2020 +0200 +++ b/sat_frontends/quick_frontend/quick_chat.py Tue Apr 14 21:00:33 2020 +0200 @@ -29,12 +29,6 @@ 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" @@ -146,9 +140,15 @@ @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 + """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): @@ -169,12 +169,24 @@ ): 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 - ) + + 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 @@ -214,14 +226,14 @@ return self.extra.get(C.MESS_KEY_ATTACHMENTS) -class MessageWidget(object): +class MessageWidget: """Base classe for widgets""" # This class does nothing and is only used to have a common ancestor pass -class Occupant(object): +class Occupant: """Occupant metadata""" def __init__(self, parent, data, profile): @@ -616,7 +628,7 @@ if self.type == C.CHAT_ONE2ONE: special = self.host.contact_lists[self.profile].getCache( - self.target, C.CONTACT_SPECIAL, create_if_not_found=True + self.target, C.CONTACT_SPECIAL, create_if_not_found=True, default=None ) if special == C.CONTACT_SPECIAL_GROUP: # we have a private conversation @@ -900,11 +912,11 @@ else: mess_data.status = status - def onAvatar(self, entity, filename, profile): + 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": filename}) + self.occupants[entity.resource].update({"avatar": avatar_data}) except KeyError: # can happen for a message in history where the # entity is not here anymore @@ -913,7 +925,7 @@ for m in list(self.messages.values()): if m.nick == entity.resource: for w in m.widgets: - w.update({"avatar": filename}) + w.update({"avatar": avatar_data}) else: if ( entity.bare == self.target.bare @@ -923,7 +935,7 @@ for m in list(self.messages.values()): if m.from_jid.bare == entity.bare: for w in m.widgets: - w.update({"avatar": filename}) + w.update({"avatar": avatar_data}) quick_widgets.register(QuickChat) diff -r 1af840e84af7 -r 6cf4bd6972c2 sat_frontends/quick_frontend/quick_contact_list.py --- a/sat_frontends/quick_frontend/quick_contact_list.py Tue Apr 14 20:36:24 2020 +0200 +++ b/sat_frontends/quick_frontend/quick_contact_list.py Tue Apr 14 21:00:33 2020 +0200 @@ -105,17 +105,11 @@ callback=self._showOfflineContacts, ) - # FIXME: workaround for a pyjamas issue: calling hash on a class method always - # return a different value if that method is defined directly within the - # class (with the "def" keyword) - self.presenceListener = self.onPresenceUpdate - self.host.addListener("presence", self.presenceListener, [self.profile]) - self.nickListener = self.onNickUpdate - self.host.addListener("nick", self.nickListener, [self.profile]) - self.notifListener = self.onNotification - self.host.addListener("notification", self.notifListener, [self.profile]) - # notifListener only update the entity, so we can re-use it - self.host.addListener("notificationsClear", self.notifListener, [self.profile]) + self.host.addListener("presence", self.onPresenceUpdate, [self.profile]) + self.host.addListener("nicknames", self.onNicknamesUpdate, [self.profile]) + self.host.addListener("notification", self.onNotification, [self.profile]) + # onNotification only updates the entity, so we can re-use it + self.host.addListener("notificationsClear", self.onNotification, [self.profile]) @property def whoami(self): @@ -164,7 +158,7 @@ [ entity for entity in self._roster - if self.getCache(entity, C.PRESENCE_SHOW) is not None + if self.getCache(entity, C.PRESENCE_SHOW, default=None) is not None ] ) @@ -255,7 +249,9 @@ def fill(self): handler.fill(self.profile) - def getCache(self, entity, name=None, bare_default=True, create_if_not_found=False): + def getCache( + self, entity, name=None, bare_default=True, create_if_not_found=False, + default=Exception): """Return a cache value for a contact @param entity(jid.JID): entity of the contact from who we want data @@ -272,8 +268,12 @@ If None, bare_default will be set to False if entity is in a room, True else @param create_if_not_found(bool): if True, create contact if it's not found in cache + @param default(object): value to return when name is not found in cache + if Exception is used, a KeyError will be returned + otherwise, the given value will be used @return: full cache if no name is given, or value of "name", or None @raise NotFound: entity not found in cache + @raise KeyError: name not found in cache """ # FIXME: resource handling need to be reworked # FIXME: bare_default work for requesting full jid to get bare jid, @@ -291,6 +291,10 @@ raise exceptions.NotFound if name is None: + if default is not Exception: + raise exceptions.InternalError( + "default value can only Exception when name is not specified" + ) # full cache is requested return cache @@ -313,22 +317,28 @@ elif entity.resource: try: return cache[C.CONTACT_RESOURCES][entity.resource][name] - except KeyError: + except KeyError as e: if bare_default is None: bare_default = not self.isRoom(entity.bare) if not bare_default: - return None + if default is Exception: + raise e + else: + return default try: return cache[name] - except KeyError: - return None + except KeyError as e: + if default is Exception: + raise e + else: + return default def setCache(self, entity, name, value): """Set or update value for one data in cache @param entity(JID): entity to update - @param name(unicode): value to set or update + @param name(str): value to set or update """ self.setContact(entity, attributes={name: value}) @@ -391,7 +401,7 @@ @param special_type: one of special type (e.g. C.CONTACT_SPECIAL_GROUP) @return (bool): True if entity is from this special type """ - return self.getCache(entity, C.CONTACT_SPECIAL) == special_type + return self.getCache(entity, C.CONTACT_SPECIAL, default=None) == special_type def setSpecial(self, entity, special_type): """Set special flag on an entity @@ -417,7 +427,7 @@ continue if ( special_type is not None - and self.getCache(entity, C.CONTACT_SPECIAL) != special_type + and self.getCache(entity, C.CONTACT_SPECIAL, default=None) != special_type ): continue yield entity @@ -443,7 +453,7 @@ """Add a contact to the list if it doesn't exist, else update it. This method can be called with groups=None for the purpose of updating - the contact's attributes (e.g. nickname). In that case, the groups + the contact's attributes (e.g. nicknames). In that case, the groups attribute must not be set to the default group but ignored. If not, you may move your contact from its actual group(s) to the default one. @@ -454,7 +464,6 @@ if entity is a full jid, attributes will be cached in for the full jid only @param groups (list): list of groups or None to ignore the groups membership. @param attributes (dict): attibutes of the added jid or to update - if attribute value is None, it will be removed @param in_roster (bool): True if contact is from roster """ if attributes is None: @@ -506,8 +515,8 @@ else: self._specials.add(entity) cache[C.CONTACT_MAIN_RESOURCE] = None - if 'nick' in cache: - del cache['nick'] + if 'nicknames' in cache: + del cache['nicknames'] # now the attributes we keep in cache # XXX: if entity is a full jid, we store the value for the resource only @@ -517,23 +526,18 @@ else cache ) for attribute, value in attributes.items(): - if value is None: - # XXX: pyjamas hack: we need to use pop instead of del - try: - cache_attr[attribute].pop(value) - except KeyError: - pass - else: - if attribute == "nick" and self.isSpecial( - entity, C.CONTACT_SPECIAL_GROUP - ): - # we don't want to keep nick for MUC rooms - # FIXME: this is here as plugin XEP-0054 can link resource's nick - # with bare jid which in the case of MUC - # set the nick for the whole MUC - # resulting in bad name displayed in some frontends - continue - cache_attr[attribute] = value + if attribute == "nicknames" and self.isSpecial( + entity, C.CONTACT_SPECIAL_GROUP + ): + # we don't want to keep nicknames for MUC rooms + # FIXME: this is here as plugin XEP-0054 can link resource's nick + # with bare jid which in the case of MUC + # set the nick for the whole MUC + # resulting in bad name displayed in some frontends + # FIXME: with plugin XEP-0054 + plugin identity refactoring, this + # may not be needed anymore… + continue + cache_attr[attribute] = value # we can update the display if needed if self.entityVisible(entity_bare): @@ -554,7 +558,7 @@ """ try: show = self.getCache(entity, C.PRESENCE_SHOW) - except exceptions.NotFound: + except (exceptions.NotFound, KeyError): return False if check_resource: @@ -682,15 +686,15 @@ elif was_visible: self.update([entity], C.UPDATE_DELETE, self.profile) - def onNickUpdate(self, entity, new_nick, profile): - """Update entity's nick + def onNicknamesUpdate(self, entity, nicknames, profile): + """Update entity's nicknames @param entity(jid.JID): entity updated - @param new_nick(unicode): new nick of the entity + @param nicknames(list[unicode]): nicknames of the entity @param profile: %(doc_profile)s """ assert profile == self.profile - self.setCache(entity, "nick", new_nick) + self.setCache(entity, "nicknames", nicknames) def onNotification(self, entity, notif, profile): """Update entity with notification @@ -1093,6 +1097,8 @@ - C.UPDATE_MODIFY: entity updated - C.UPDATE_ADD: entity added - C.UPDATE_SELECTION: selection modified + - C.UPDATE_STRUCTURE: organisation of items is modified (not items + themselves) or None for undefined update Note that events correspond to addition, modification and deletion of items on the whole contact list. If the contact is visible or not