diff sat/plugins/plugin_xep_0054.py @ 3254:6cf4bd6972c2

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`
author Goffi <goffi@goffi.org>
date Tue, 14 Apr 2020 21:00:33 +0200
parents 5afd7416ca2d
children 7aa01e262e05
line wrap: on
line diff
--- 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 <http://www.gnu.org/licenses/>.
 
-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 <presence/> stanza with vcard data
 
         Check for avatar information, and get VCard if needed
-        @param presend(domish.Element): <presence/> stanza
+        @param presence(domish.Element): <presence/> 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))