comparison sat/plugins/plugin_xep_0084.py @ 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
children 524856bd7b19
comparison
equal deleted inserted replaced
3819:4f02e339d184 3820:88e332cec47b
1 #!/usr/bin/env python3
2
3 # Libervia plugin for XEP-0084
4 # Copyright (C) 2009-2022 Jérôme Poisson (goffi@goffi.org)
5
6 # This program is free software: you can redistribute it and/or modify
7 # it under the terms of the GNU Affero General Public License as published by
8 # the Free Software Foundation, either version 3 of the License, or
9 # (at your option) any later version.
10
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU Affero General Public License for more details.
15
16 # You should have received a copy of the GNU Affero General Public License
17 # along with this program. If not, see <http://www.gnu.org/licenses/>.
18
19 from typing import Optional, Dict, Any
20 from pathlib import Path
21 from base64 import b64decode, b64encode
22
23 from twisted.internet import defer
24 from twisted.words.protocols.jabber.xmlstream import XMPPHandler
25 from twisted.words.protocols.jabber import jid, error
26 from twisted.words.xish import domish
27 from zope.interface import implementer
28 from wokkel import disco, iwokkel, pubsub
29
30 from sat.core.constants import Const as C
31 from sat.core.i18n import _
32 from sat.core.log import getLogger
33 from sat.core.core_types import SatXMPPEntity
34 from sat.core import exceptions
35
36
37 log = getLogger(__name__)
38
39 IMPORT_NAME = "XEP-0084"
40
41 PLUGIN_INFO = {
42 C.PI_NAME: "User Avatar",
43 C.PI_IMPORT_NAME: IMPORT_NAME,
44 C.PI_TYPE: C.PLUG_TYPE_XEP,
45 C.PI_MODES: C.PLUG_MODE_BOTH,
46 C.PI_PROTOCOLS: ["XEP-0084"],
47 C.PI_DEPENDENCIES: ["IDENTITY", "XEP-0060", "XEP-0163"],
48 C.PI_MAIN: "XEP_0084",
49 C.PI_HANDLER: "yes",
50 C.PI_DESCRIPTION: _("""XEP-0084 (User Avatar) implementation"""),
51 }
52
53 NS_AVATAR = "urn:xmpp:avatar"
54 NS_AVATAR_METADATA = f"{NS_AVATAR}:metadata"
55 NS_AVATAR_DATA = f"{NS_AVATAR}:data"
56
57
58 class XEP_0084:
59 namespace_metadata = NS_AVATAR_METADATA
60 namespace_data = NS_AVATAR_DATA
61
62 def __init__(self, host):
63 log.info(_("XEP-0084 (User Avatar) plugin initialization"))
64 host.registerNamespace("avatar_metadata", NS_AVATAR_METADATA)
65 host.registerNamespace("avatar_data", NS_AVATAR_DATA)
66 self.host = host
67 self._p = host.plugins["XEP-0060"]
68 self._i = host.plugins['IDENTITY']
69 self._i.register(
70 IMPORT_NAME,
71 "avatar",
72 self.getAvatar,
73 self.setAvatar,
74 priority=2000
75 )
76 host.plugins["XEP-0163"].addPEPEvent(
77 None, NS_AVATAR_METADATA, self._onMetadataUpdate
78 )
79
80 def getHandler(self, client):
81 return XEP_0084_Handler()
82
83 def _onMetadataUpdate(self, itemsEvent, profile):
84 client = self.host.getClient(profile)
85 defer.ensureDeferred(self.onMetadataUpdate(client, itemsEvent))
86
87 async def onMetadataUpdate(
88 self,
89 client: SatXMPPEntity,
90 itemsEvent: pubsub.ItemsEvent
91 ) -> None:
92 entity = client.jid.userhostJID()
93 avatar_metadata = await self.getAvatar(client, entity)
94 await self._i.update(client, IMPORT_NAME, "avatar", avatar_metadata, entity)
95
96 async def getAvatar(
97 self,
98 client: SatXMPPEntity,
99 entity_jid: jid.JID
100 ) -> Optional[dict]:
101 """Get avatar data
102
103 @param entity: entity to get avatar from
104 @return: avatar metadata, or None if no avatar has been found
105 """
106 service = entity_jid.userhostJID()
107 # metadata
108 try:
109 items, __ = await self._p.getItems(
110 client,
111 service,
112 NS_AVATAR_METADATA,
113 max_items=1
114 )
115 except exceptions.NotFound:
116 return None
117
118 if not items:
119 return None
120
121 item_elt = items[0]
122 try:
123 metadata_elt = next(item_elt.elements(NS_AVATAR_METADATA, "metadata"))
124 except StopIteration:
125 log.warning(f"missing metadata element: {item_elt.toXml()}")
126 return None
127
128 for info_elt in metadata_elt.elements(NS_AVATAR_METADATA, "info"):
129 try:
130 metadata = {
131 "id": str(info_elt["id"]),
132 "size": int(info_elt["bytes"]),
133 "media_type": str(info_elt["type"])
134 }
135 avatar_id = metadata["id"]
136 if not avatar_id:
137 raise ValueError
138 except (KeyError, ValueError):
139 log.warning(f"invalid <info> element: {item_elt.toXml()}")
140 return None
141 # FIXME: to simplify, we only handle image/png for now
142 if metadata["media_type"] == "image/png":
143 break
144 else:
145 # mandatory image/png is missing, or avatar is disabled
146 # (https://xmpp.org/extensions/xep-0084.html#pub-disable)
147 return None
148
149 cache_data = self.host.common_cache.getMetadata(avatar_id)
150 if not cache_data:
151 try:
152 data_items, __ = await self._p.getItems(
153 client,
154 service,
155 NS_AVATAR_DATA,
156 item_ids=[avatar_id]
157 )
158 data_item_elt = data_items[0]
159 except (error.StanzaError, IndexError) as e:
160 log.warning(
161 f"Can't retrieve avatar of {service.full()} with ID {avatar_id!r}: "
162 f"{e}"
163 )
164 return None
165 try:
166 avatar_buf = b64decode(
167 str(next(data_item_elt.elements(NS_AVATAR_DATA, "data")))
168 )
169 except Exception as e:
170 log.warning(
171 f"invalid data element for {service.full()} with avatar ID "
172 f"{avatar_id!r}: {e}\n{data_item_elt.toXml()}"
173 )
174 return None
175 with self.host.common_cache.cacheData(
176 IMPORT_NAME,
177 avatar_id,
178 metadata["media_type"]
179 ) as f:
180 f.write(avatar_buf)
181 cache_data = {
182 "path": Path(f.name),
183 "mime_type": metadata["media_type"]
184 }
185
186 return self._i.avatarBuildMetadata(
187 cache_data['path'], cache_data['mime_type'], avatar_id
188 )
189
190 def buildItemDataElt(self, avatar_data: Dict[str, Any]) -> domish.Element:
191 """Generate the item for the data node
192
193 @param avatar_data: data as build by identity plugin (need to be filled with
194 "cache_uid" and "base64" keys)
195 """
196 data_elt = domish.Element((NS_AVATAR_DATA, "data"))
197 data_elt.addContent(avatar_data["base64"])
198 return pubsub.Item(id=avatar_data["cache_uid"], payload=data_elt)
199
200 def buildItemMetadataElt(self, avatar_data: Dict[str, Any]) -> domish.Element:
201 """Generate the item for the metadata node
202
203 @param avatar_data: data as build by identity plugin (need to be filled with
204 "cache_uid", "path", and "media_type" keys)
205 """
206 metadata_elt = domish.Element((NS_AVATAR_METADATA, "metadata"))
207 info_elt = metadata_elt.addElement("info")
208 # FIXME: we only fill required elements for now (see
209 # https://xmpp.org/extensions/xep-0084.html#table-1)
210 info_elt["id"] = avatar_data["cache_uid"]
211 info_elt["type"] = avatar_data["media_type"]
212 info_elt["bytes"] = str(avatar_data["path"].stat().st_size)
213 return pubsub.Item(id=self._p.ID_SINGLETON, payload=metadata_elt)
214
215 async def setAvatar(
216 self,
217 client: SatXMPPEntity,
218 avatar_data: Dict[str, Any],
219 entity: jid.JID
220 ) -> None:
221 """Set avatar of the profile
222
223 @param avatar_data(dict): data of the image to use as avatar, as built by
224 IDENTITY plugin.
225 @param entity(jid.JID): entity whose avatar must be changed
226 """
227 service = entity.userhostJID()
228
229 # Data
230 await self._p.createIfNewNode(
231 client,
232 service,
233 NS_AVATAR_DATA,
234 options={
235 self._p.OPT_ACCESS_MODEL: self._p.ACCESS_OPEN,
236 self._p.OPT_PERSIST_ITEMS: 1,
237 self._p.OPT_MAX_ITEMS: 1,
238 }
239 )
240 item_data_elt = self.buildItemDataElt(avatar_data)
241 await self._p.sendItems(client, service, NS_AVATAR_DATA, [item_data_elt])
242
243 # Metadata
244 await self._p.createIfNewNode(
245 client,
246 service,
247 NS_AVATAR_METADATA,
248 options={
249 self._p.OPT_ACCESS_MODEL: self._p.ACCESS_OPEN,
250 self._p.OPT_PERSIST_ITEMS: 1,
251 self._p.OPT_MAX_ITEMS: 1,
252 }
253 )
254 item_metadata_elt = self.buildItemMetadataElt(avatar_data)
255 await self._p.sendItems(client, service, NS_AVATAR_METADATA, [item_metadata_elt])
256
257
258 @implementer(iwokkel.IDisco)
259 class XEP_0084_Handler(XMPPHandler):
260
261 def getDiscoInfo(self, requestor, service, nodeIdentifier=""):
262 return [
263 disco.DiscoFeature(NS_AVATAR_METADATA),
264 disco.DiscoFeature(NS_AVATAR_DATA)
265 ]
266
267 def getDiscoItems(self, requestor, service, nodeIdentifier=""):
268 return []