diff --git a/sat/plugins/plugin_xep_0084.py b/sat/plugins/plugin_xep_0084.py
new file mode 100644
index 00000000..77b7537d
--- /dev/null
+++ b/sat/plugins/plugin_xep_0084.py
@@ -0,0 +1,268 @@
+#!/usr/bin/env python3
+
+# Libervia plugin for XEP-0084
+# Copyright (C) 2009-2022 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+from typing import Optional, Dict, Any
+from pathlib import Path
+from base64 import b64decode, b64encode
+
+from twisted.internet import defer
+from twisted.words.protocols.jabber.xmlstream import XMPPHandler
+from twisted.words.protocols.jabber import jid, error
+from twisted.words.xish import domish
+from zope.interface import implementer
+from wokkel import disco, iwokkel, pubsub
+
+from sat.core.constants import Const as C
+from sat.core.i18n import _
+from sat.core.log import getLogger
+from sat.core.core_types import SatXMPPEntity
+from sat.core import exceptions
+
+
+log = getLogger(__name__)
+
+IMPORT_NAME = "XEP-0084"
+
+PLUGIN_INFO = {
+ C.PI_NAME: "User Avatar",
+ C.PI_IMPORT_NAME: IMPORT_NAME,
+ C.PI_TYPE: C.PLUG_TYPE_XEP,
+ C.PI_MODES: C.PLUG_MODE_BOTH,
+ C.PI_PROTOCOLS: ["XEP-0084"],
+ C.PI_DEPENDENCIES: ["IDENTITY", "XEP-0060", "XEP-0163"],
+ C.PI_MAIN: "XEP_0084",
+ C.PI_HANDLER: "yes",
+ C.PI_DESCRIPTION: _("""XEP-0084 (User Avatar) implementation"""),
+}
+
+NS_AVATAR = "urn:xmpp:avatar"
+NS_AVATAR_METADATA = f"{NS_AVATAR}:metadata"
+NS_AVATAR_DATA = f"{NS_AVATAR}:data"
+
+
+class XEP_0084:
+ namespace_metadata = NS_AVATAR_METADATA
+ namespace_data = NS_AVATAR_DATA
+
+ def __init__(self, host):
+ log.info(_("XEP-0084 (User Avatar) plugin initialization"))
+ host.registerNamespace("avatar_metadata", NS_AVATAR_METADATA)
+ host.registerNamespace("avatar_data", NS_AVATAR_DATA)
+ self.host = host
+ self._p = host.plugins["XEP-0060"]
+ self._i = host.plugins['IDENTITY']
+ self._i.register(
+ IMPORT_NAME,
+ "avatar",
+ self.getAvatar,
+ self.setAvatar,
+ priority=2000
+ )
+ host.plugins["XEP-0163"].addPEPEvent(
+ None, NS_AVATAR_METADATA, self._onMetadataUpdate
+ )
+
+ def getHandler(self, client):
+ return XEP_0084_Handler()
+
+ def _onMetadataUpdate(self, itemsEvent, profile):
+ client = self.host.getClient(profile)
+ defer.ensureDeferred(self.onMetadataUpdate(client, itemsEvent))
+
+ async def onMetadataUpdate(
+ self,
+ client: SatXMPPEntity,
+ itemsEvent: pubsub.ItemsEvent
+ ) -> None:
+ entity = client.jid.userhostJID()
+ avatar_metadata = await self.getAvatar(client, entity)
+ await self._i.update(client, IMPORT_NAME, "avatar", avatar_metadata, entity)
+
+ async def getAvatar(
+ self,
+ client: SatXMPPEntity,
+ entity_jid: jid.JID
+ ) -> Optional[dict]:
+ """Get avatar data
+
+ @param entity: entity to get avatar from
+ @return: avatar metadata, or None if no avatar has been found
+ """
+ service = entity_jid.userhostJID()
+ # metadata
+ try:
+ items, __ = await self._p.getItems(
+ client,
+ service,
+ NS_AVATAR_METADATA,
+ max_items=1
+ )
+ except exceptions.NotFound:
+ return None
+
+ if not items:
+ return None
+
+ item_elt = items[0]
+ try:
+ metadata_elt = next(item_elt.elements(NS_AVATAR_METADATA, "metadata"))
+ except StopIteration:
+ log.warning(f"missing metadata element: {item_elt.toXml()}")
+ return None
+
+ for info_elt in metadata_elt.elements(NS_AVATAR_METADATA, "info"):
+ try:
+ metadata = {
+ "id": str(info_elt["id"]),
+ "size": int(info_elt["bytes"]),
+ "media_type": str(info_elt["type"])
+ }
+ avatar_id = metadata["id"]
+ if not avatar_id:
+ raise ValueError
+ except (KeyError, ValueError):
+ log.warning(f"invalid element: {item_elt.toXml()}")
+ return None
+ # FIXME: to simplify, we only handle image/png for now
+ if metadata["media_type"] == "image/png":
+ break
+ else:
+ # mandatory image/png is missing, or avatar is disabled
+ # (https://xmpp.org/extensions/xep-0084.html#pub-disable)
+ return None
+
+ cache_data = self.host.common_cache.getMetadata(avatar_id)
+ if not cache_data:
+ try:
+ data_items, __ = await self._p.getItems(
+ client,
+ service,
+ NS_AVATAR_DATA,
+ item_ids=[avatar_id]
+ )
+ data_item_elt = data_items[0]
+ except (error.StanzaError, IndexError) as e:
+ log.warning(
+ f"Can't retrieve avatar of {service.full()} with ID {avatar_id!r}: "
+ f"{e}"
+ )
+ return None
+ try:
+ avatar_buf = b64decode(
+ str(next(data_item_elt.elements(NS_AVATAR_DATA, "data")))
+ )
+ except Exception as e:
+ log.warning(
+ f"invalid data element for {service.full()} with avatar ID "
+ f"{avatar_id!r}: {e}\n{data_item_elt.toXml()}"
+ )
+ return None
+ with self.host.common_cache.cacheData(
+ IMPORT_NAME,
+ avatar_id,
+ metadata["media_type"]
+ ) as f:
+ f.write(avatar_buf)
+ cache_data = {
+ "path": Path(f.name),
+ "mime_type": metadata["media_type"]
+ }
+
+ return self._i.avatarBuildMetadata(
+ cache_data['path'], cache_data['mime_type'], avatar_id
+ )
+
+ def buildItemDataElt(self, avatar_data: Dict[str, Any]) -> domish.Element:
+ """Generate the item for the data node
+
+ @param avatar_data: data as build by identity plugin (need to be filled with
+ "cache_uid" and "base64" keys)
+ """
+ data_elt = domish.Element((NS_AVATAR_DATA, "data"))
+ data_elt.addContent(avatar_data["base64"])
+ return pubsub.Item(id=avatar_data["cache_uid"], payload=data_elt)
+
+ def buildItemMetadataElt(self, avatar_data: Dict[str, Any]) -> domish.Element:
+ """Generate the item for the metadata node
+
+ @param avatar_data: data as build by identity plugin (need to be filled with
+ "cache_uid", "path", and "media_type" keys)
+ """
+ metadata_elt = domish.Element((NS_AVATAR_METADATA, "metadata"))
+ info_elt = metadata_elt.addElement("info")
+ # FIXME: we only fill required elements for now (see
+ # https://xmpp.org/extensions/xep-0084.html#table-1)
+ info_elt["id"] = avatar_data["cache_uid"]
+ info_elt["type"] = avatar_data["media_type"]
+ info_elt["bytes"] = str(avatar_data["path"].stat().st_size)
+ return pubsub.Item(id=self._p.ID_SINGLETON, payload=metadata_elt)
+
+ async def setAvatar(
+ self,
+ client: SatXMPPEntity,
+ avatar_data: Dict[str, Any],
+ entity: jid.JID
+ ) -> None:
+ """Set avatar of the profile
+
+ @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
+ """
+ service = entity.userhostJID()
+
+ # Data
+ await self._p.createIfNewNode(
+ client,
+ service,
+ NS_AVATAR_DATA,
+ options={
+ self._p.OPT_ACCESS_MODEL: self._p.ACCESS_OPEN,
+ self._p.OPT_PERSIST_ITEMS: 1,
+ self._p.OPT_MAX_ITEMS: 1,
+ }
+ )
+ item_data_elt = self.buildItemDataElt(avatar_data)
+ await self._p.sendItems(client, service, NS_AVATAR_DATA, [item_data_elt])
+
+ # Metadata
+ await self._p.createIfNewNode(
+ client,
+ service,
+ NS_AVATAR_METADATA,
+ options={
+ self._p.OPT_ACCESS_MODEL: self._p.ACCESS_OPEN,
+ self._p.OPT_PERSIST_ITEMS: 1,
+ self._p.OPT_MAX_ITEMS: 1,
+ }
+ )
+ item_metadata_elt = self.buildItemMetadataElt(avatar_data)
+ await self._p.sendItems(client, service, NS_AVATAR_METADATA, [item_metadata_elt])
+
+
+@implementer(iwokkel.IDisco)
+class XEP_0084_Handler(XMPPHandler):
+
+ def getDiscoInfo(self, requestor, service, nodeIdentifier=""):
+ return [
+ disco.DiscoFeature(NS_AVATAR_METADATA),
+ disco.DiscoFeature(NS_AVATAR_DATA)
+ ]
+
+ def getDiscoItems(self, requestor, service, nodeIdentifier=""):
+ return []