XEP-0084 implementation was only supporting PNG, but some client (notably Conversations) don't publish PNG, so we have to handle a few other formats.
347 lines
13 KiB
Python
347 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
|
|
# Libervia plugin for XEP-0084
|
|
# Copyright (C) 2009-2026 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 base64 import b64decode
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from twisted.internet import defer
|
|
from twisted.words.protocols.jabber import error, jid
|
|
from twisted.words.protocols.jabber.xmlstream import XMPPHandler
|
|
from twisted.words.xish import domish
|
|
from zope.interface import implementer
|
|
|
|
from libervia.backend.core import exceptions
|
|
from libervia.backend.core.constants import Const as C
|
|
from libervia.backend.core.core_types import SatXMPPEntity
|
|
from libervia.backend.core.i18n import _
|
|
from libervia.backend.core.log import getLogger
|
|
from tx_xmpp import disco, itx_xmpp, pubsub
|
|
|
|
log = getLogger(__name__)
|
|
|
|
# Supported image formats, in priority order
|
|
FORMATS = ("png", "jpeg", "webp", "gif", "svg+xml")
|
|
|
|
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.register_namespace("avatar_metadata", NS_AVATAR_METADATA)
|
|
host.register_namespace("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.get_avatar, self.set_avatar, priority=2000
|
|
)
|
|
host.plugins["XEP-0163"].add_pep_event(
|
|
None, NS_AVATAR_METADATA, self._on_metadata_update
|
|
)
|
|
|
|
def get_handler(self, client):
|
|
return XEP_0084_Handler()
|
|
|
|
def _on_metadata_update(self, itemsEvent, profile):
|
|
client = self.host.get_client(profile)
|
|
defer.ensureDeferred(self.on_metadata_update(client, itemsEvent))
|
|
|
|
class UnsupportedFormat(Exception):
|
|
"""Avatar metadata exists but no supported format is found."""
|
|
|
|
@staticmethod
|
|
def _parse_metadata_info(
|
|
item_elt: domish.Element,
|
|
) -> tuple[str, str, int] | None:
|
|
"""Parse avatar metadata item and extract ID, media type, and size.
|
|
|
|
@param item_elt: pubsub item element containing metadata.
|
|
@return: (avatar_id, media_type, size) if a supported format is found,
|
|
None if avatar is disabled or no supported format is found.
|
|
"""
|
|
try:
|
|
metadata_elt = next(item_elt.elements(NS_AVATAR_METADATA, "metadata"))
|
|
except StopIteration:
|
|
log.warning(f"missing metadata element: {item_elt.toXml()}")
|
|
return None
|
|
|
|
# (priority, id, type, size)
|
|
candidates: list[tuple[int, str, str, int]] = []
|
|
for info_elt in metadata_elt.elements(NS_AVATAR_METADATA, "info"):
|
|
try:
|
|
avatar_id = str(info_elt["id"])
|
|
media_type = str(info_elt["type"])
|
|
size = int(info_elt["bytes"])
|
|
except (KeyError, ValueError):
|
|
log.warning(f"invalid <info> element: {item_elt.toXml()}")
|
|
continue
|
|
if not avatar_id:
|
|
# Avatar is explicitly disabled
|
|
# (https://xmpp.org/extensions/xep-0084.html#pub-disable)
|
|
return None
|
|
if not media_type.startswith("image/"):
|
|
continue
|
|
subtype = media_type.split("/", 1)[1].lower()
|
|
try:
|
|
priority = FORMATS.index(subtype)
|
|
except ValueError:
|
|
continue
|
|
candidates.append((priority, avatar_id, media_type, size))
|
|
|
|
if not candidates:
|
|
raise XEP_0084.UnsupportedFormat(
|
|
f"no supported format found in avatar metadata"
|
|
)
|
|
|
|
candidates.sort(key=lambda c: c[0])
|
|
_, avatar_id, media_type, size = candidates[0]
|
|
return (avatar_id, media_type, size)
|
|
|
|
async def on_metadata_update(
|
|
self, client: SatXMPPEntity, itemsEvent: pubsub.ItemsEvent
|
|
) -> None:
|
|
"""Handle PEP avatar metadata update event.
|
|
|
|
@param client: Client instance.
|
|
@param itemsEvent: pubsub items event with avatar metadata.
|
|
"""
|
|
entity = itemsEvent.sender
|
|
service = entity.userhostJID()
|
|
items = itemsEvent.items
|
|
|
|
if not items or items[0].name == "retract":
|
|
log.debug(f"Avatar retracted for {entity.full()}")
|
|
await self._i.update(client, IMPORT_NAME, "avatar", None, entity)
|
|
return
|
|
|
|
try:
|
|
parsed = self._parse_metadata_info(items[0])
|
|
except XEP_0084.UnsupportedFormat:
|
|
log.warning(f"Avatar for {entity.full()} is in unsupported format.")
|
|
parsed = None
|
|
if parsed is None:
|
|
log.debug(f"Avatar disabled for {entity.full()}")
|
|
await self._i.update(client, IMPORT_NAME, "avatar", None, entity)
|
|
return
|
|
|
|
avatar_id, media_type, _size = parsed
|
|
|
|
# Check if avatar is already cached
|
|
try:
|
|
cached = self.host.memory.get_entity_datum(client, entity, "avatar")
|
|
except (KeyError, exceptions.UnknownEntityError):
|
|
cached = None
|
|
|
|
if cached is not None:
|
|
cached_uid = cached.get("cache_uid") if isinstance(cached, dict) else None
|
|
if cached_uid == avatar_id:
|
|
log.debug(f"Avatar already cached for {entity.full()} (id={avatar_id})")
|
|
return
|
|
log.debug(
|
|
f"Avatar changed for {entity.full()} (old={cached_uid}, new={avatar_id})"
|
|
)
|
|
|
|
# Fetch avatar data if not already cached
|
|
cache_data = self.host.common_cache.get_metadata(avatar_id)
|
|
if not cache_data:
|
|
cache_data = await self._fetch_avatar_data(
|
|
client, service, avatar_id, media_type
|
|
)
|
|
if cache_data is None:
|
|
log.warning(f"Failed to fetch avatar data for {entity.full()}")
|
|
return
|
|
|
|
log.debug(f"Avatar updated for {entity.full()} (id={avatar_id})")
|
|
avatar_metadata = self._i.avatar_build_metadata(
|
|
cache_data["path"], cache_data["mime_type"], avatar_id
|
|
)
|
|
await self._i.update(client, IMPORT_NAME, "avatar", avatar_metadata, entity)
|
|
|
|
async def _fetch_avatar_data(
|
|
self,
|
|
client: SatXMPPEntity,
|
|
service: jid.JID,
|
|
avatar_id: str,
|
|
media_type: str,
|
|
) -> dict | None:
|
|
"""Fetch avatar data from PEP data node and cache it.
|
|
|
|
@return: Cache data dict with path and mime_type, or None on failure.
|
|
"""
|
|
try:
|
|
data_items, __ = await self._p.get_items(
|
|
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.cache_data(IMPORT_NAME, avatar_id, media_type) as f:
|
|
f.write(avatar_buf)
|
|
return {"path": Path(f.name), "mime_type": media_type}
|
|
|
|
async def get_avatar(self, client: SatXMPPEntity, entity_jid: jid.JID) -> dict | None:
|
|
"""Get avatar data by fetching from PEP metadata node.
|
|
|
|
@param client: Client instance.
|
|
@param entity_jid: entity to get avatar for.
|
|
@return: avatar metadata dict if found, None otherwise.
|
|
"""
|
|
service = entity_jid.userhostJID()
|
|
try:
|
|
data_items, __ = await self._p.get_items(client, service, NS_AVATAR_METADATA)
|
|
except error.StanzaError:
|
|
return None
|
|
|
|
if not data_items:
|
|
return None
|
|
|
|
try:
|
|
parsed = self._parse_metadata_info(data_items[0])
|
|
except XEP_0084.UnsupportedFormat:
|
|
return None
|
|
if parsed is None:
|
|
return None
|
|
|
|
avatar_id, media_type, _size = parsed
|
|
|
|
cache_data = self.host.common_cache.get_metadata(avatar_id)
|
|
if not cache_data:
|
|
cache_data = await self._fetch_avatar_data(
|
|
client, service, avatar_id, media_type
|
|
)
|
|
if cache_data is None:
|
|
return None
|
|
|
|
return self._i.avatar_build_metadata(
|
|
cache_data["path"], cache_data["mime_type"], avatar_id
|
|
)
|
|
|
|
def build_item_data_elt(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 build_item_metadata_elt(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 set_avatar(
|
|
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.create_if_new_node(
|
|
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.build_item_data_elt(avatar_data)
|
|
await self._p.send_items(client, service, NS_AVATAR_DATA, [item_data_elt])
|
|
|
|
# Metadata
|
|
await self._p.create_if_new_node(
|
|
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.build_item_metadata_elt(avatar_data)
|
|
await self._p.send_items(client, service, NS_AVATAR_METADATA, [item_metadata_elt])
|
|
|
|
|
|
@implementer(itx_xmpp.IDisco)
|
|
class XEP_0084_Handler(XMPPHandler):
|
|
|
|
def getDiscoInfo(self, requestor, service, nodeIdentifier=""):
|
|
# NS_AVATAR_METADATA is already advertised by ``add_pep_event``.
|
|
# Only NS_AVATAR_DATA needs to be advertised here.
|
|
return [
|
|
disco.DiscoFeature(NS_AVATAR_DATA),
|
|
]
|
|
|
|
def getDiscoItems(self, requestor, service, nodeIdentifier=""):
|
|
return []
|