Mercurial > libervia-backend
view sat/plugins/plugin_xep_0054.py @ 4046:0e3ce379aae3
tests (unit): tests for plugin XEP-0176:
fix 419
author | Goffi <goffi@goffi.org> |
---|---|
date | Mon, 15 May 2023 16:23:50 +0200 |
parents | 524856bd7b19 |
children |
line wrap: on
line source
#!/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 sat.core import exceptions from sat.core.i18n import _ from sat.core.constants import Const as C from sat.core.log import getLogger from sat.core.xmpp import SatXMPPEntity from sat.memory import persistent from sat.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))