diff libervia/backend/plugins/plugin_xep_0054.py @ 4071:4b842c1fb686

refactoring: renamed `sat` package to `libervia.backend`
author Goffi <goffi@goffi.org>
date Fri, 02 Jun 2023 11:49:51 +0200
parents sat/plugins/plugin_xep_0054.py@524856bd7b19
children 0d7bb4df2343
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0054.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,475 @@
+#!/usr/bin/env python3
+
+# SAT plugin for managing xep-0054
+# Copyright (C) 2009-2021 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
+# 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/>.
+
+import io
+from base64 import b64decode, b64encode
+from hashlib import sha1
+from pathlib import Path
+from typing import Optional
+from zope.interface import implementer
+from twisted.internet import threads, defer
+from twisted.words.protocols.jabber import jid, error
+from twisted.words.xish import domish
+from twisted.python.failure import Failure
+from wokkel import disco, iwokkel
+from libervia.backend.core import exceptions
+from libervia.backend.core.i18n import _
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+from libervia.backend.core.xmpp import SatXMPPEntity
+from libervia.backend.memory import persistent
+from libervia.backend.tools import image
+
+log = getLogger(__name__)
+
+try:
+    from PIL import Image
+except:
+    raise exceptions.MissingModule(
+        "Missing module pillow, please download/install it from https://python-pillow.github.io"
+    )
+
+from twisted.words.protocols.jabber.xmlstream import XMPPHandler
+
+IMPORT_NAME = "XEP-0054"
+
+PLUGIN_INFO = {
+    C.PI_NAME: "XEP 0054 Plugin",
+    C.PI_IMPORT_NAME: IMPORT_NAME,
+    C.PI_TYPE: "XEP",
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    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"""),
+}
+
+IQ_GET = '/iq[@type="get"]'
+NS_VCARD = "vcard-temp"
+VCARD_REQUEST = IQ_GET + '/vCard[@xmlns="' + NS_VCARD + '"]'  # TODO: manage requests
+
+PRESENCE = "/presence"
+NS_VCARD_UPDATE = "vcard-temp:x:update"
+VCARD_UPDATE = PRESENCE + '/x[@xmlns="' + NS_VCARD_UPDATE + '"]'
+
+HASH_SHA1_EMPTY = "da39a3ee5e6b4b0d3255bfef95601890afd80709"
+
+
+class XEP_0054(object):
+
+    def __init__(self, host):
+        log.info(_("Plugin XEP_0054 initialization"))
+        self.host = host
+        self._i = host.plugins['IDENTITY']
+        self._i.register(IMPORT_NAME, 'avatar', self.get_avatar, self.set_avatar)
+        self._i.register(IMPORT_NAME, 'nicknames', self.get_nicknames, self.set_nicknames)
+        host.trigger.add("presence_available", self.presence_available_trigger)
+
+    def get_handler(self, client):
+        return XEP_0054_handler(self)
+
+    def presence_available_trigger(self, presence_elt, client):
+        try:
+            avatar_hash = client._xep_0054_avatar_hashes[client.jid.userhost()]
+        except KeyError:
+            log.info(
+                _("No avatar in cache for {profile}")
+                .format(profile=client.profile))
+            return True
+        x_elt = domish.Element((NS_VCARD_UPDATE, "x"))
+        x_elt.addElement("photo", content=avatar_hash)
+        presence_elt.addChild(x_elt)
+        return True
+
+    async def profile_connecting(self, client):
+        client._xep_0054_avatar_hashes = persistent.PersistentDict(
+            NS_VCARD, client.profile)
+        await client._xep_0054_avatar_hashes.load()
+
+    def save_photo(self, client, photo_elt, entity):
+        """Parse a <PHOTO> photo_elt and save the picture"""
+        # XXX: this method is launched in a separate thread
+        try:
+            mime_type = str(next(photo_elt.elements(NS_VCARD, "TYPE")))
+        except StopIteration:
+            mime_type = None
+        else:
+            if not mime_type:
+                # MIME type not known, we'll try autodetection below
+                mime_type = None
+            elif mime_type == "image/x-png":
+                # XXX: this old MIME type is still used by some clients
+                mime_type = "image/png"
+
+        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.full()))
+            raise Failure(exceptions.NotFound())
+
+        log.debug(_("Decoding binary"))
+        decoded = b64decode(buf)
+        del buf
+
+        if mime_type is None:
+            log.debug(
+                f"no media type found specified for {entity}'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}'s avatar"
+                log.warning(msg)
+                raise Failure(exceptions.DataError(msg))
+
+        image_hash = sha1(decoded).hexdigest()
+        with self.host.common_cache.cache_data(
+            PLUGIN_INFO["import_name"],
+            image_hash,
+            mime_type,
+        ) as f:
+            f.write(decoded)
+        return image_hash
+
+    async def v_card_2_dict(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_elt.elements():
+            if elem.name == "FN":
+                vcard_dict["fullname"] = str(elem)
+            elif elem.name == "NICKNAME":
+                nickname = vcard_dict["nickname"] = str(elem)
+                await self._i.update(
+                    client,
+                    IMPORT_NAME,
+                    "nicknames",
+                    [nickname],
+                    entity_jid
+                )
+            elif elem.name == "URL":
+                vcard_dict["website"] = str(elem)
+            elif elem.name == "EMAIL":
+                vcard_dict["email"] = str(elem)
+            elif elem.name == "BDAY":
+                vcard_dict["birthday"] = str(elem)
+            elif elem.name == "PHOTO":
+                # TODO: handle EXTVAL
+                try:
+                    avatar_hash = await threads.deferToThread(
+                        self.save_photo, client, elem, entity_jid
+                    )
+                except (exceptions.DataError, exceptions.NotFound):
+                    avatar_hash = ""
+                    vcard_dict["avatar"] = avatar_hash
+                except Exception as e:
+                    log.error(f"avatar saving error: {e}")
+                    avatar_hash = None
+                else:
+                    vcard_dict["avatar"] = avatar_hash
+                if avatar_hash is not None:
+                    await client._xep_0054_avatar_hashes.aset(
+                        entity_jid.full(), avatar_hash)
+
+                    if avatar_hash:
+                        avatar_cache = self.host.common_cache.get_metadata(avatar_hash)
+                        await self._i.update(
+                            client,
+                            IMPORT_NAME,
+                            "avatar",
+                            {
+                                'path': avatar_cache['path'],
+                                'filename': avatar_cache['filename'],
+                                'media_type': avatar_cache['mime_type'],
+                                'cache_uid': avatar_hash
+                            },
+                            entity_jid
+                        )
+                    else:
+                        await self._i.update(
+                            client, IMPORT_NAME, "avatar", None, entity_jid)
+            else:
+                log.debug("FIXME: [{}] VCard_elt tag is not managed yet".format(elem.name))
+
+        return vcard_dict
+
+    async def get_vcard_element(self, client, entity_jid):
+        """Retrieve domish.Element of a VCard
+
+        @param entity_jid(jid.JID): entity from who we need the vCard
+        @raise DataError: we got an invalid answer
+        """
+        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:
+            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}")
+
+    async def update_vcard_elt(self, client, entity_jid, to_replace):
+        """Create a vcard element to replace some metadata
+
+        @param to_replace(list[str]): list of vcard element names to remove
+        """
+        try:
+            # we first check if a vcard already exists, to keep data
+            vcard_elt = await self.get_vcard_element(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 get_card(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.get_identity_jid(client, entity_jid)
+        log.debug(f"Asking for {entity_jid}'s VCard")
+        try:
+            vcard_elt = await self.get_vcard_element(client, entity_jid)
+        except exceptions.DataError:
+            self._i.update(client, IMPORT_NAME, "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:
+            log.debug(_("VCard found"))
+            return await self.v_card_2_dict(client, vcard_elt, entity_jid)
+
+    async def get_avatar(
+            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
+        """
+        entity_jid = self._i.get_identity_jid(client, entity_jid)
+        hashes_cache = client._xep_0054_avatar_hashes
+        vcard = await self.get_card(client, entity_jid)
+        if vcard is None:
+            return None
+        try:
+            avatar_hash = hashes_cache[entity_jid.full()]
+        except KeyError:
+            if 'avatar' in vcard:
+                raise exceptions.InternalError(
+                    "No avatar hash while avatar is found in vcard")
+            return None
+
+        if not avatar_hash:
+            return None
+
+        avatar_cache = self.host.common_cache.get_metadata(avatar_hash)
+        return self._i.avatar_build_metadata(
+                avatar_cache['path'], avatar_cache['mime_type'], avatar_hash)
+
+    async def set_avatar(self, client, avatar_data, entity):
+        """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
+        """
+        vcard_elt = await self.update_vcard_elt(client, entity, ['PHOTO'])
+
+        iq_elt = client.IQ()
+        iq_elt.addChild(vcard_elt)
+        # metadata with encoded image are now filled at the right size/format
+        photo_elt = vcard_elt.addElement("PHOTO")
+        photo_elt.addElement("TYPE", content=avatar_data["media_type"])
+        photo_elt.addElement("BINVAL", content=avatar_data["base64"])
+
+        await iq_elt.send()
+
+        # FIXME: should send the current presence, not always "available" !
+        await client.presence.available()
+
+    async def get_nicknames(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.get_card(client, entity)
+        try:
+            return [vcard_data['nickname']]
+        except (KeyError, TypeError):
+            return []
+
+    async def set_nicknames(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.update_vcard_elt(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)
+class XEP_0054_handler(XMPPHandler):
+
+    def __init__(self, plugin_parent):
+        self.plugin_parent = plugin_parent
+        self.host = plugin_parent.host
+
+    def connectionInitialized(self):
+        self.xmlstream.addObserver(VCARD_UPDATE, self._update)
+
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_VCARD)]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []
+
+    async def update(self, presence):
+        """Called on <presence/> stanza with vcard data
+
+        Check for avatar information, and get VCard if needed
+        @param presence(domish.Element): <presence/> stanza
+        """
+        client = self.parent
+        entity_jid = self.plugin_parent._i.get_identity_jid(
+            client, jid.JID(presence["from"]))
+
+        try:
+            x_elt = next(presence.elements(NS_VCARD_UPDATE, "x"))
+        except StopIteration:
+            return
+
+        try:
+            photo_elt = next(x_elt.elements(NS_VCARD_UPDATE, "photo"))
+        except StopIteration:
+            return
+
+        given_hash = str(photo_elt).strip()
+        if given_hash == HASH_SHA1_EMPTY:
+            given_hash = ""
+
+        hashes_cache = client._xep_0054_avatar_hashes
+
+        old_hash = hashes_cache.get(entity_jid.full())
+
+        if old_hash == given_hash:
+            # no change, we can return…
+            if given_hash:
+                # …but we double check that avatar is in cache
+                avatar_cache = self.host.common_cache.get_metadata(given_hash)
+                if avatar_cache is None:
+                    log.debug(
+                        f"Avatar for [{entity_jid}] is known but not in cache, we get "
+                        f"it"
+                    )
+                    # get_card will put the avatar in cache
+                    await self.plugin_parent.get_card(client, entity_jid)
+                else:
+                    log.debug(f"avatar for {entity_jid} is already in cache")
+            return
+
+        if given_hash is None:
+            # XXX: we use empty string to indicate that there is no avatar
+            given_hash = ""
+
+        await hashes_cache.aset(entity_jid.full(), given_hash)
+
+        if not given_hash:
+            await self.plugin_parent._i.update(
+                client, IMPORT_NAME, "avatar", None, entity_jid)
+            # the avatar has been removed, no need to go further
+            return
+
+        avatar_cache = self.host.common_cache.get_metadata(given_hash)
+        if avatar_cache is not None:
+            log.debug(
+                f"New avatar found for [{entity_jid}], it's already in cache, we use it"
+            )
+            await self.plugin_parent._i.update(
+                client,
+                IMPORT_NAME, "avatar",
+                {
+                    'path': avatar_cache['path'],
+                    'filename': avatar_cache['filename'],
+                    'media_type': avatar_cache['mime_type'],
+                    'cache_uid': given_hash,
+                },
+                entity_jid
+            )
+        else:
+            log.debug(
+                "New avatar found for [{entity_jid}], requesting vcard"
+            )
+            vcard = await self.plugin_parent.get_card(client, entity_jid)
+            if vcard is None:
+                log.warning(f"Unexpected empty vCard for {entity_jid}")
+                return
+            computed_hash = client._xep_0054_avatar_hashes[entity_jid.full()]
+            if computed_hash != given_hash:
+                log.warning(
+                    "computed hash differs from given hash for {entity}:\n"
+                    "computed: {computed}\ngiven: {given}".format(
+                        entity=entity_jid, computed=computed_hash, given=given_hash
+                    )
+                )
+
+    def _update(self, presence):
+        defer.ensureDeferred(self.update(presence))