changeset 3820:88e332cec47b

plugin XEP-0084: "User Avatar" implementation: rel 368
author Goffi <goffi@goffi.org>
date Wed, 29 Jun 2022 11:59:07 +0200
parents 4f02e339d184
children 0b1c30ff2cbb
files sat/plugins/plugin_xep_0084.py
diffstat 1 files changed, 268 insertions(+), 0 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/sat/plugins/plugin_xep_0084.py	Wed Jun 29 11:59:07 2022 +0200
@@ -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 <http://www.gnu.org/licenses/>.
+
+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 <info> 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 []