Mercurial > libervia-backend
comparison 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 |
comparison
equal
deleted
inserted
replaced
4070:d10748475025 | 4071:4b842c1fb686 |
---|---|
1 #!/usr/bin/env python3 | |
2 | |
3 # SAT plugin for managing xep-0054 | |
4 # Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) | |
5 # Copyright (C) 2014 Emmanuel Gil Peyrot (linkmauve@linkmauve.fr) | |
6 | |
7 # This program is free software: you can redistribute it and/or modify | |
8 # it under the terms of the GNU Affero General Public License as published by | |
9 # the Free Software Foundation, either version 3 of the License, or | |
10 # (at your option) any later version. | |
11 | |
12 # This program is distributed in the hope that it will be useful, | |
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of | |
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
15 # GNU Affero General Public License for more details. | |
16 | |
17 # You should have received a copy of the GNU Affero General Public License | |
18 # along with this program. If not, see <http://www.gnu.org/licenses/>. | |
19 | |
20 import io | |
21 from base64 import b64decode, b64encode | |
22 from hashlib import sha1 | |
23 from pathlib import Path | |
24 from typing import Optional | |
25 from zope.interface import implementer | |
26 from twisted.internet import threads, defer | |
27 from twisted.words.protocols.jabber import jid, error | |
28 from twisted.words.xish import domish | |
29 from twisted.python.failure import Failure | |
30 from wokkel import disco, iwokkel | |
31 from libervia.backend.core import exceptions | |
32 from libervia.backend.core.i18n import _ | |
33 from libervia.backend.core.constants import Const as C | |
34 from libervia.backend.core.log import getLogger | |
35 from libervia.backend.core.xmpp import SatXMPPEntity | |
36 from libervia.backend.memory import persistent | |
37 from libervia.backend.tools import image | |
38 | |
39 log = getLogger(__name__) | |
40 | |
41 try: | |
42 from PIL import Image | |
43 except: | |
44 raise exceptions.MissingModule( | |
45 "Missing module pillow, please download/install it from https://python-pillow.github.io" | |
46 ) | |
47 | |
48 from twisted.words.protocols.jabber.xmlstream import XMPPHandler | |
49 | |
50 IMPORT_NAME = "XEP-0054" | |
51 | |
52 PLUGIN_INFO = { | |
53 C.PI_NAME: "XEP 0054 Plugin", | |
54 C.PI_IMPORT_NAME: IMPORT_NAME, | |
55 C.PI_TYPE: "XEP", | |
56 C.PI_MODES: C.PLUG_MODE_BOTH, | |
57 C.PI_PROTOCOLS: ["XEP-0054", "XEP-0153"], | |
58 C.PI_DEPENDENCIES: ["IDENTITY"], | |
59 C.PI_RECOMMENDATIONS: [], | |
60 C.PI_MAIN: "XEP_0054", | |
61 C.PI_HANDLER: "yes", | |
62 C.PI_DESCRIPTION: _("""Implementation of vcard-temp"""), | |
63 } | |
64 | |
65 IQ_GET = '/iq[@type="get"]' | |
66 NS_VCARD = "vcard-temp" | |
67 VCARD_REQUEST = IQ_GET + '/vCard[@xmlns="' + NS_VCARD + '"]' # TODO: manage requests | |
68 | |
69 PRESENCE = "/presence" | |
70 NS_VCARD_UPDATE = "vcard-temp:x:update" | |
71 VCARD_UPDATE = PRESENCE + '/x[@xmlns="' + NS_VCARD_UPDATE + '"]' | |
72 | |
73 HASH_SHA1_EMPTY = "da39a3ee5e6b4b0d3255bfef95601890afd80709" | |
74 | |
75 | |
76 class XEP_0054(object): | |
77 | |
78 def __init__(self, host): | |
79 log.info(_("Plugin XEP_0054 initialization")) | |
80 self.host = host | |
81 self._i = host.plugins['IDENTITY'] | |
82 self._i.register(IMPORT_NAME, 'avatar', self.get_avatar, self.set_avatar) | |
83 self._i.register(IMPORT_NAME, 'nicknames', self.get_nicknames, self.set_nicknames) | |
84 host.trigger.add("presence_available", self.presence_available_trigger) | |
85 | |
86 def get_handler(self, client): | |
87 return XEP_0054_handler(self) | |
88 | |
89 def presence_available_trigger(self, presence_elt, client): | |
90 try: | |
91 avatar_hash = client._xep_0054_avatar_hashes[client.jid.userhost()] | |
92 except KeyError: | |
93 log.info( | |
94 _("No avatar in cache for {profile}") | |
95 .format(profile=client.profile)) | |
96 return True | |
97 x_elt = domish.Element((NS_VCARD_UPDATE, "x")) | |
98 x_elt.addElement("photo", content=avatar_hash) | |
99 presence_elt.addChild(x_elt) | |
100 return True | |
101 | |
102 async def profile_connecting(self, client): | |
103 client._xep_0054_avatar_hashes = persistent.PersistentDict( | |
104 NS_VCARD, client.profile) | |
105 await client._xep_0054_avatar_hashes.load() | |
106 | |
107 def save_photo(self, client, photo_elt, entity): | |
108 """Parse a <PHOTO> photo_elt and save the picture""" | |
109 # XXX: this method is launched in a separate thread | |
110 try: | |
111 mime_type = str(next(photo_elt.elements(NS_VCARD, "TYPE"))) | |
112 except StopIteration: | |
113 mime_type = None | |
114 else: | |
115 if not mime_type: | |
116 # MIME type not known, we'll try autodetection below | |
117 mime_type = None | |
118 elif mime_type == "image/x-png": | |
119 # XXX: this old MIME type is still used by some clients | |
120 mime_type = "image/png" | |
121 | |
122 try: | |
123 buf = str(next(photo_elt.elements(NS_VCARD, "BINVAL"))) | |
124 except StopIteration: | |
125 log.warning("BINVAL element not found") | |
126 raise Failure(exceptions.NotFound()) | |
127 | |
128 if not buf: | |
129 log.warning("empty avatar for {jid}".format(jid=entity.full())) | |
130 raise Failure(exceptions.NotFound()) | |
131 | |
132 log.debug(_("Decoding binary")) | |
133 decoded = b64decode(buf) | |
134 del buf | |
135 | |
136 if mime_type is None: | |
137 log.debug( | |
138 f"no media type found specified for {entity}'s avatar, trying to " | |
139 f"guess") | |
140 | |
141 try: | |
142 mime_type = image.guess_type(io.BytesIO(decoded)) | |
143 except IOError as e: | |
144 log.warning(f"Can't open avatar buffer: {e}") | |
145 | |
146 if mime_type is None: | |
147 msg = f"Can't find media type for {entity}'s avatar" | |
148 log.warning(msg) | |
149 raise Failure(exceptions.DataError(msg)) | |
150 | |
151 image_hash = sha1(decoded).hexdigest() | |
152 with self.host.common_cache.cache_data( | |
153 PLUGIN_INFO["import_name"], | |
154 image_hash, | |
155 mime_type, | |
156 ) as f: | |
157 f.write(decoded) | |
158 return image_hash | |
159 | |
160 async def v_card_2_dict(self, client, vcard_elt, entity_jid): | |
161 """Convert a VCard_elt to a dict, and save binaries""" | |
162 log.debug(("parsing vcard_elt")) | |
163 vcard_dict = {} | |
164 | |
165 for elem in vcard_elt.elements(): | |
166 if elem.name == "FN": | |
167 vcard_dict["fullname"] = str(elem) | |
168 elif elem.name == "NICKNAME": | |
169 nickname = vcard_dict["nickname"] = str(elem) | |
170 await self._i.update( | |
171 client, | |
172 IMPORT_NAME, | |
173 "nicknames", | |
174 [nickname], | |
175 entity_jid | |
176 ) | |
177 elif elem.name == "URL": | |
178 vcard_dict["website"] = str(elem) | |
179 elif elem.name == "EMAIL": | |
180 vcard_dict["email"] = str(elem) | |
181 elif elem.name == "BDAY": | |
182 vcard_dict["birthday"] = str(elem) | |
183 elif elem.name == "PHOTO": | |
184 # TODO: handle EXTVAL | |
185 try: | |
186 avatar_hash = await threads.deferToThread( | |
187 self.save_photo, client, elem, entity_jid | |
188 ) | |
189 except (exceptions.DataError, exceptions.NotFound): | |
190 avatar_hash = "" | |
191 vcard_dict["avatar"] = avatar_hash | |
192 except Exception as e: | |
193 log.error(f"avatar saving error: {e}") | |
194 avatar_hash = None | |
195 else: | |
196 vcard_dict["avatar"] = avatar_hash | |
197 if avatar_hash is not None: | |
198 await client._xep_0054_avatar_hashes.aset( | |
199 entity_jid.full(), avatar_hash) | |
200 | |
201 if avatar_hash: | |
202 avatar_cache = self.host.common_cache.get_metadata(avatar_hash) | |
203 await self._i.update( | |
204 client, | |
205 IMPORT_NAME, | |
206 "avatar", | |
207 { | |
208 'path': avatar_cache['path'], | |
209 'filename': avatar_cache['filename'], | |
210 'media_type': avatar_cache['mime_type'], | |
211 'cache_uid': avatar_hash | |
212 }, | |
213 entity_jid | |
214 ) | |
215 else: | |
216 await self._i.update( | |
217 client, IMPORT_NAME, "avatar", None, entity_jid) | |
218 else: | |
219 log.debug("FIXME: [{}] VCard_elt tag is not managed yet".format(elem.name)) | |
220 | |
221 return vcard_dict | |
222 | |
223 async def get_vcard_element(self, client, entity_jid): | |
224 """Retrieve domish.Element of a VCard | |
225 | |
226 @param entity_jid(jid.JID): entity from who we need the vCard | |
227 @raise DataError: we got an invalid answer | |
228 """ | |
229 iq_elt = client.IQ("get") | |
230 iq_elt["from"] = client.jid.full() | |
231 iq_elt["to"] = entity_jid.full() | |
232 iq_elt.addElement("vCard", NS_VCARD) | |
233 iq_ret_elt = await iq_elt.send(entity_jid.full()) | |
234 try: | |
235 return next(iq_ret_elt.elements(NS_VCARD, "vCard")) | |
236 except StopIteration: | |
237 log.warning(_( | |
238 "vCard element not found for {entity_jid}: {xml}" | |
239 ).format(entity_jid=entity_jid, xml=iq_ret_elt.toXml())) | |
240 raise exceptions.DataError(f"no vCard element found for {entity_jid}") | |
241 | |
242 async def update_vcard_elt(self, client, entity_jid, to_replace): | |
243 """Create a vcard element to replace some metadata | |
244 | |
245 @param to_replace(list[str]): list of vcard element names to remove | |
246 """ | |
247 try: | |
248 # we first check if a vcard already exists, to keep data | |
249 vcard_elt = await self.get_vcard_element(client, entity_jid) | |
250 except error.StanzaError as e: | |
251 if e.condition == "item-not-found": | |
252 vcard_elt = domish.Element((NS_VCARD, "vCard")) | |
253 else: | |
254 raise e | |
255 except exceptions.DataError: | |
256 vcard_elt = domish.Element((NS_VCARD, "vCard")) | |
257 else: | |
258 # the vcard exists, we need to remove elements that we'll replace | |
259 for elt_name in to_replace: | |
260 try: | |
261 elt = next(vcard_elt.elements(NS_VCARD, elt_name)) | |
262 except StopIteration: | |
263 pass | |
264 else: | |
265 vcard_elt.children.remove(elt) | |
266 | |
267 return vcard_elt | |
268 | |
269 async def get_card(self, client, entity_jid): | |
270 """Ask server for VCard | |
271 | |
272 @param entity_jid(jid.JID): jid from which we want the VCard | |
273 @result(dict): vCard data | |
274 """ | |
275 entity_jid = self._i.get_identity_jid(client, entity_jid) | |
276 log.debug(f"Asking for {entity_jid}'s VCard") | |
277 try: | |
278 vcard_elt = await self.get_vcard_element(client, entity_jid) | |
279 except exceptions.DataError: | |
280 self._i.update(client, IMPORT_NAME, "avatar", None, entity_jid) | |
281 except Exception as e: | |
282 log.warning(_( | |
283 "Can't get vCard for {entity_jid}: {e}" | |
284 ).format(entity_jid=entity_jid, e=e)) | |
285 else: | |
286 log.debug(_("VCard found")) | |
287 return await self.v_card_2_dict(client, vcard_elt, entity_jid) | |
288 | |
289 async def get_avatar( | |
290 self, | |
291 client: SatXMPPEntity, | |
292 entity_jid: jid.JID | |
293 ) -> Optional[dict]: | |
294 """Get avatar data | |
295 | |
296 @param entity: entity to get avatar from | |
297 @return: avatar metadata, or None if no avatar has been found | |
298 """ | |
299 entity_jid = self._i.get_identity_jid(client, entity_jid) | |
300 hashes_cache = client._xep_0054_avatar_hashes | |
301 vcard = await self.get_card(client, entity_jid) | |
302 if vcard is None: | |
303 return None | |
304 try: | |
305 avatar_hash = hashes_cache[entity_jid.full()] | |
306 except KeyError: | |
307 if 'avatar' in vcard: | |
308 raise exceptions.InternalError( | |
309 "No avatar hash while avatar is found in vcard") | |
310 return None | |
311 | |
312 if not avatar_hash: | |
313 return None | |
314 | |
315 avatar_cache = self.host.common_cache.get_metadata(avatar_hash) | |
316 return self._i.avatar_build_metadata( | |
317 avatar_cache['path'], avatar_cache['mime_type'], avatar_hash) | |
318 | |
319 async def set_avatar(self, client, avatar_data, entity): | |
320 """Set avatar of the profile | |
321 | |
322 @param avatar_data(dict): data of the image to use as avatar, as built by | |
323 IDENTITY plugin. | |
324 @param entity(jid.JID): entity whose avatar must be changed | |
325 """ | |
326 vcard_elt = await self.update_vcard_elt(client, entity, ['PHOTO']) | |
327 | |
328 iq_elt = client.IQ() | |
329 iq_elt.addChild(vcard_elt) | |
330 # metadata with encoded image are now filled at the right size/format | |
331 photo_elt = vcard_elt.addElement("PHOTO") | |
332 photo_elt.addElement("TYPE", content=avatar_data["media_type"]) | |
333 photo_elt.addElement("BINVAL", content=avatar_data["base64"]) | |
334 | |
335 await iq_elt.send() | |
336 | |
337 # FIXME: should send the current presence, not always "available" ! | |
338 await client.presence.available() | |
339 | |
340 async def get_nicknames(self, client, entity): | |
341 """get nick from cache, or check vCard | |
342 | |
343 @param entity(jid.JID): entity to get nick from | |
344 @return(list[str]): nicknames found | |
345 """ | |
346 vcard_data = await self.get_card(client, entity) | |
347 try: | |
348 return [vcard_data['nickname']] | |
349 except (KeyError, TypeError): | |
350 return [] | |
351 | |
352 async def set_nicknames(self, client, nicknames, entity): | |
353 """Update our vCard and set a nickname | |
354 | |
355 @param nicknames(list[str]): new nicknames to use | |
356 only first item of this list will be used here | |
357 """ | |
358 nick = nicknames[0].strip() | |
359 | |
360 vcard_elt = await self.update_vcard_elt(client, entity, ['NICKNAME']) | |
361 | |
362 if nick: | |
363 vcard_elt.addElement((NS_VCARD, "NICKNAME"), content=nick) | |
364 iq_elt = client.IQ() | |
365 iq_elt.addChild(vcard_elt) | |
366 await iq_elt.send() | |
367 | |
368 | |
369 @implementer(iwokkel.IDisco) | |
370 class XEP_0054_handler(XMPPHandler): | |
371 | |
372 def __init__(self, plugin_parent): | |
373 self.plugin_parent = plugin_parent | |
374 self.host = plugin_parent.host | |
375 | |
376 def connectionInitialized(self): | |
377 self.xmlstream.addObserver(VCARD_UPDATE, self._update) | |
378 | |
379 def getDiscoInfo(self, requestor, target, nodeIdentifier=""): | |
380 return [disco.DiscoFeature(NS_VCARD)] | |
381 | |
382 def getDiscoItems(self, requestor, target, nodeIdentifier=""): | |
383 return [] | |
384 | |
385 async def update(self, presence): | |
386 """Called on <presence/> stanza with vcard data | |
387 | |
388 Check for avatar information, and get VCard if needed | |
389 @param presence(domish.Element): <presence/> stanza | |
390 """ | |
391 client = self.parent | |
392 entity_jid = self.plugin_parent._i.get_identity_jid( | |
393 client, jid.JID(presence["from"])) | |
394 | |
395 try: | |
396 x_elt = next(presence.elements(NS_VCARD_UPDATE, "x")) | |
397 except StopIteration: | |
398 return | |
399 | |
400 try: | |
401 photo_elt = next(x_elt.elements(NS_VCARD_UPDATE, "photo")) | |
402 except StopIteration: | |
403 return | |
404 | |
405 given_hash = str(photo_elt).strip() | |
406 if given_hash == HASH_SHA1_EMPTY: | |
407 given_hash = "" | |
408 | |
409 hashes_cache = client._xep_0054_avatar_hashes | |
410 | |
411 old_hash = hashes_cache.get(entity_jid.full()) | |
412 | |
413 if old_hash == given_hash: | |
414 # no change, we can return… | |
415 if given_hash: | |
416 # …but we double check that avatar is in cache | |
417 avatar_cache = self.host.common_cache.get_metadata(given_hash) | |
418 if avatar_cache is None: | |
419 log.debug( | |
420 f"Avatar for [{entity_jid}] is known but not in cache, we get " | |
421 f"it" | |
422 ) | |
423 # get_card will put the avatar in cache | |
424 await self.plugin_parent.get_card(client, entity_jid) | |
425 else: | |
426 log.debug(f"avatar for {entity_jid} is already in cache") | |
427 return | |
428 | |
429 if given_hash is None: | |
430 # XXX: we use empty string to indicate that there is no avatar | |
431 given_hash = "" | |
432 | |
433 await hashes_cache.aset(entity_jid.full(), given_hash) | |
434 | |
435 if not given_hash: | |
436 await self.plugin_parent._i.update( | |
437 client, IMPORT_NAME, "avatar", None, entity_jid) | |
438 # the avatar has been removed, no need to go further | |
439 return | |
440 | |
441 avatar_cache = self.host.common_cache.get_metadata(given_hash) | |
442 if avatar_cache is not None: | |
443 log.debug( | |
444 f"New avatar found for [{entity_jid}], it's already in cache, we use it" | |
445 ) | |
446 await self.plugin_parent._i.update( | |
447 client, | |
448 IMPORT_NAME, "avatar", | |
449 { | |
450 'path': avatar_cache['path'], | |
451 'filename': avatar_cache['filename'], | |
452 'media_type': avatar_cache['mime_type'], | |
453 'cache_uid': given_hash, | |
454 }, | |
455 entity_jid | |
456 ) | |
457 else: | |
458 log.debug( | |
459 "New avatar found for [{entity_jid}], requesting vcard" | |
460 ) | |
461 vcard = await self.plugin_parent.get_card(client, entity_jid) | |
462 if vcard is None: | |
463 log.warning(f"Unexpected empty vCard for {entity_jid}") | |
464 return | |
465 computed_hash = client._xep_0054_avatar_hashes[entity_jid.full()] | |
466 if computed_hash != given_hash: | |
467 log.warning( | |
468 "computed hash differs from given hash for {entity}:\n" | |
469 "computed: {computed}\ngiven: {given}".format( | |
470 entity=entity_jid, computed=computed_hash, given=given_hash | |
471 ) | |
472 ) | |
473 | |
474 def _update(self, presence): | |
475 defer.ensureDeferred(self.update(presence)) |