Mercurial > libervia-backend
comparison sat/plugins/plugin_xep_0054.py @ 3254:6cf4bd6972c2
core, frontends: avatar refactoring:
/!\ huge commit
Avatar logic has been reworked around the IDENTITY plugin: plugins able to handle avatar
or other identity related metadata (like nicknames) register to IDENTITY plugin in the
same way as for other features like download/upload. Once registered, IDENTITY plugin will
call them when suitable in order of priority, and handle caching.
Methods to manage those metadata from frontend now use serialised data.
For now `avatar` and `nicknames` are handled:
- `avatar` is now a dict with `path` + metadata like `media_type`, instead of just a string
path
- `nicknames` is now a list of nicknames in order of priority. This list is never empty,
and `nicknames[0]` should be the preferred nickname to use by frontends in most cases.
In addition to contact specified nicknames, user set nickname (the one set in roster) is
used in priority when available.
Among the side changes done with this commit, there are:
- a new `contactGet` bridge method to get roster metadata for a single contact
- SatPresenceProtocol.send returns a Deferred to check when it has actually been sent
- memory's methods to handle entities data now use `client` as first argument
- metadata filter can be specified with `getIdentity`
- `getAvatar` and `setAvatar` are now part of the IDENTITY plugin instead of XEP-0054 (and
there signature has changed)
- `isRoom` and `getBareOrFull` are now part of XEP-0045 plugin
- jp avatar/get command uses `xdg-open` first when available for `--show` flag
- `--no-cache` has been added to jp avatar/get and identity/get
- jp identity/set has been simplified, explicit options (`--nickname` only for now) are
used instead of `--field`. `--field` may come back in the future if necessary for extra
data.
- QuickContactList `SetContact` now handle None as a value, and doesn't use it to delete the
metadata anymore
- improved cache handling for `metadata` and `nicknames` in quick frontend
- new `default` argument in QuickContactList `getCache`
author | Goffi <goffi@goffi.org> |
---|---|
date | Tue, 14 Apr 2020 21:00:33 +0200 |
parents | 5afd7416ca2d |
children | 7aa01e262e05 |
comparison
equal
deleted
inserted
replaced
3253:1af840e84af7 | 3254:6cf4bd6972c2 |
---|---|
1 #!/usr/bin/env python3 | 1 #!/usr/bin/env python3 |
2 | |
3 | 2 |
4 # SAT plugin for managing xep-0054 | 3 # SAT plugin for managing xep-0054 |
5 # Copyright (C) 2009-2020 Jérôme Poisson (goffi@goffi.org) | 4 # Copyright (C) 2009-2020 Jérôme Poisson (goffi@goffi.org) |
6 # Copyright (C) 2014 Emmanuel Gil Peyrot (linkmauve@linkmauve.fr) | 5 # Copyright (C) 2014 Emmanuel Gil Peyrot (linkmauve@linkmauve.fr) |
7 | 6 |
16 # GNU Affero General Public License for more details. | 15 # GNU Affero General Public License for more details. |
17 | 16 |
18 # You should have received a copy of the GNU Affero General Public License | 17 # You should have received a copy of the GNU Affero General Public License |
19 # along with this program. If not, see <http://www.gnu.org/licenses/>. | 18 # along with this program. If not, see <http://www.gnu.org/licenses/>. |
20 | 19 |
21 import mimetypes | 20 import io |
22 from base64 import b64decode, b64encode | 21 from base64 import b64decode, b64encode |
23 from hashlib import sha1 | 22 from hashlib import sha1 |
23 from pathlib import Path | |
24 from zope.interface import implementer | 24 from zope.interface import implementer |
25 from twisted.internet import threads, defer | 25 from twisted.internet import threads, defer |
26 from twisted.words.protocols.jabber import jid, error | 26 from twisted.words.protocols.jabber import jid, error |
27 from twisted.words.xish import domish | 27 from twisted.words.xish import domish |
28 from twisted.python.failure import Failure | 28 from twisted.python.failure import Failure |
30 from sat.core import exceptions | 30 from sat.core import exceptions |
31 from sat.core.i18n import _ | 31 from sat.core.i18n import _ |
32 from sat.core.constants import Const as C | 32 from sat.core.constants import Const as C |
33 from sat.core.log import getLogger | 33 from sat.core.log import getLogger |
34 from sat.memory import persistent | 34 from sat.memory import persistent |
35 from sat.tools import image | |
35 | 36 |
36 log = getLogger(__name__) | 37 log = getLogger(__name__) |
37 | 38 |
38 try: | 39 try: |
39 from PIL import Image | 40 from PIL import Image |
40 except: | 41 except: |
41 raise exceptions.MissingModule( | 42 raise exceptions.MissingModule( |
42 "Missing module pillow, please download/install it from https://python-pillow.github.io" | 43 "Missing module pillow, please download/install it from https://python-pillow.github.io" |
43 ) | 44 ) |
44 import io | |
45 | 45 |
46 try: | 46 try: |
47 from twisted.words.protocols.xmlstream import XMPPHandler | 47 from twisted.words.protocols.xmlstream import XMPPHandler |
48 except ImportError: | 48 except ImportError: |
49 from wokkel.subprotocols import XMPPHandler | 49 from wokkel.subprotocols import XMPPHandler |
50 | 50 |
51 AVATAR_PATH = "avatars" | |
52 # AVATAR_DIM = (64, 64) # FIXME: dim are not adapted to modern resolutions ! | |
53 AVATAR_DIM = (128, 128) | |
54 | |
55 IQ_GET = '/iq[@type="get"]' | |
56 NS_VCARD = "vcard-temp" | |
57 VCARD_REQUEST = IQ_GET + '/vCard[@xmlns="' + NS_VCARD + '"]' # TODO: manage requests | |
58 | |
59 PRESENCE = "/presence" | |
60 NS_VCARD_UPDATE = "vcard-temp:x:update" | |
61 VCARD_UPDATE = PRESENCE + '/x[@xmlns="' + NS_VCARD_UPDATE + '"]' | |
62 | |
63 CACHED_DATA = {"avatar", "nick"} | |
64 MAX_AGE = 60 * 60 * 24 * 365 | |
65 | 51 |
66 PLUGIN_INFO = { | 52 PLUGIN_INFO = { |
67 C.PI_NAME: "XEP 0054 Plugin", | 53 C.PI_NAME: "XEP 0054 Plugin", |
68 C.PI_IMPORT_NAME: "XEP-0054", | 54 C.PI_IMPORT_NAME: "XEP-0054", |
69 C.PI_TYPE: "XEP", | 55 C.PI_TYPE: "XEP", |
70 C.PI_PROTOCOLS: ["XEP-0054", "XEP-0153"], | 56 C.PI_PROTOCOLS: ["XEP-0054", "XEP-0153"], |
71 C.PI_DEPENDENCIES: [], | 57 C.PI_DEPENDENCIES: ["IDENTITY"], |
72 C.PI_RECOMMENDATIONS: ["XEP-0045"], | 58 C.PI_RECOMMENDATIONS: [], |
73 C.PI_MAIN: "XEP_0054", | 59 C.PI_MAIN: "XEP_0054", |
74 C.PI_HANDLER: "yes", | 60 C.PI_HANDLER: "yes", |
75 C.PI_DESCRIPTION: _("""Implementation of vcard-temp"""), | 61 C.PI_DESCRIPTION: _("""Implementation of vcard-temp"""), |
76 } | 62 } |
77 | 63 |
64 AVATAR_PATH = "avatars" | |
65 AVATAR_DIM = (128, 128) | |
66 | |
67 IQ_GET = '/iq[@type="get"]' | |
68 NS_VCARD = "vcard-temp" | |
69 VCARD_REQUEST = IQ_GET + '/vCard[@xmlns="' + NS_VCARD + '"]' # TODO: manage requests | |
70 | |
71 PRESENCE = "/presence" | |
72 NS_VCARD_UPDATE = "vcard-temp:x:update" | |
73 VCARD_UPDATE = PRESENCE + '/x[@xmlns="' + NS_VCARD_UPDATE + '"]' | |
74 | |
75 HASH_SHA1_EMPTY = "da39a3ee5e6b4b0d3255bfef95601890afd80709" | |
76 | |
78 | 77 |
79 class XEP_0054(object): | 78 class XEP_0054(object): |
80 # TODO: - check that nickname is ok | |
81 # - refactor the code/better use of Wokkel | |
82 # - get missing values | |
83 | 79 |
84 def __init__(self, host): | 80 def __init__(self, host): |
85 log.info(_("Plugin XEP_0054 initialization")) | 81 log.info(_("Plugin XEP_0054 initialization")) |
86 self.host = host | 82 self.host = host |
87 host.bridge.addMethod( | 83 self._i = host.plugins['IDENTITY'] |
88 "avatarGet", | 84 self._i.register('avatar', self.getAvatar, self.setAvatar) |
89 ".plugin", | 85 self._i.register('nicknames', self.getNicknames, self.setNicknames) |
90 in_sign="sbbs", | |
91 out_sign="s", | |
92 method=self._getAvatar, | |
93 async_=True, | |
94 ) | |
95 host.bridge.addMethod( | |
96 "avatarSet", | |
97 ".plugin", | |
98 in_sign="ss", | |
99 out_sign="", | |
100 method=self._setAvatar, | |
101 async_=True, | |
102 ) | |
103 host.trigger.add("presence_available", self.presenceAvailableTrigger) | 86 host.trigger.add("presence_available", self.presenceAvailableTrigger) |
104 host.memory.setSignalOnUpdate("avatar") | |
105 host.memory.setSignalOnUpdate("nick") | |
106 | 87 |
107 def getHandler(self, client): | 88 def getHandler(self, client): |
108 return XEP_0054_handler(self) | 89 return XEP_0054_handler(self) |
109 | 90 |
110 def isRoom(self, client, entity_jid): | 91 def presenceAvailableTrigger(self, presence_elt, client): |
111 """Tell if a jid is a MUC one | 92 try: |
112 | 93 avatar_hash = client._xep_0054_avatar_hashes[client.jid.userhost()] |
113 @param entity_jid(jid.JID): full or bare jid of the entity check | |
114 @return (bool): True if the bare jid of the entity is a room jid | |
115 """ | |
116 try: | |
117 muc_plg = self.host.plugins["XEP-0045"] | |
118 except KeyError: | 94 except KeyError: |
119 return False | 95 log.info( |
120 | 96 _("No avatar in cache for {profile}") |
121 try: | 97 .format(profile=client.profile)) |
122 muc_plg.checkRoomJoined(client, entity_jid.userhostJID()) | |
123 except exceptions.NotFound: | |
124 return False | |
125 else: | |
126 return True | 98 return True |
127 | 99 x_elt = domish.Element((NS_VCARD_UPDATE, "x")) |
128 def getBareOrFull(self, client, jid_): | 100 x_elt.addElement("photo", content=avatar_hash) |
129 """use full jid if jid_ is an occupant of a room, bare jid else | 101 presence_elt.addChild(x_elt) |
130 | |
131 @param jid_(jid.JID): entity to test | |
132 @return (jid.JID): bare or full jid | |
133 """ | |
134 if jid_.resource: | |
135 if not self.isRoom(client, jid_): | |
136 return jid_.userhostJID() | |
137 return jid_ | |
138 | |
139 def presenceAvailableTrigger(self, presence_elt, client): | |
140 if client.jid.userhost() in client._cache_0054: | |
141 try: | |
142 avatar_hash = client._cache_0054[client.jid.userhost()]["avatar"] | |
143 except KeyError: | |
144 log.info("No avatar in cache for {}".format(client.jid.userhost())) | |
145 return True | |
146 x_elt = domish.Element((NS_VCARD_UPDATE, "x")) | |
147 x_elt.addElement("photo", content=avatar_hash) | |
148 presence_elt.addChild(x_elt) | |
149 return True | 102 return True |
150 | 103 |
151 @defer.inlineCallbacks | 104 async def profileConnecting(self, client): |
152 def profileConnecting(self, client): | 105 client._xep_0054_avatar_hashes = persistent.PersistentDict( |
153 client._cache_0054 = persistent.PersistentBinaryDict(NS_VCARD, client.profile) | 106 NS_VCARD, client.profile) |
154 yield client._cache_0054.load() | 107 await client._xep_0054_avatar_hashes.load() |
155 self._fillCachedValues(client.profile) | |
156 | |
157 def _fillCachedValues(self, profile): | |
158 # FIXME: this may need to be reworked | |
159 # the current naive approach keeps a map between all jids | |
160 # in persistent cache, then put avatar hashs in memory. | |
161 # Hashes should be shared between profiles (or not ? what | |
162 # if the avatar is different depending on who is requesting it | |
163 # this is not possible with vcard-tmp, but it is with XEP-0084). | |
164 # Loading avatar on demand per jid may be an option to investigate. | |
165 client = self.host.getClient(profile) | |
166 for jid_s, data in client._cache_0054.items(): | |
167 jid_ = jid.JID(jid_s) | |
168 for name in CACHED_DATA: | |
169 try: | |
170 value = data[name] | |
171 if value is None: | |
172 log.error( | |
173 "{name} value for {jid_} is None, ignoring".format( | |
174 name=name, jid_=jid_ | |
175 ) | |
176 ) | |
177 continue | |
178 self.host.memory.updateEntityData( | |
179 jid_, name, data[name], silent=True, profile_key=profile | |
180 ) | |
181 except KeyError: | |
182 pass | |
183 | |
184 def updateCache(self, client, jid_, name, value): | |
185 """update cache value | |
186 | |
187 save value in memory in case of change | |
188 @param jid_(jid.JID): jid of the owner of the vcard | |
189 @param name(str): name of the item which changed | |
190 @param value(unicode, None): new value of the item | |
191 None to delete | |
192 """ | |
193 jid_ = self.getBareOrFull(client, jid_) | |
194 jid_s = jid_.full() | |
195 | |
196 if value is None: | |
197 try: | |
198 self.host.memory.delEntityDatum(jid_, name, client.profile) | |
199 except (KeyError, exceptions.UnknownEntityError): | |
200 pass | |
201 if name in CACHED_DATA: | |
202 try: | |
203 del client._cache_0054[jid_s][name] | |
204 except KeyError: | |
205 pass | |
206 else: | |
207 client._cache_0054.force(jid_s) | |
208 else: | |
209 self.host.memory.updateEntityData( | |
210 jid_, name, value, profile_key=client.profile | |
211 ) | |
212 if name in CACHED_DATA: | |
213 client._cache_0054.setdefault(jid_s, {})[name] = value | |
214 client._cache_0054.force(jid_s) | |
215 | 108 |
216 def getCache(self, client, entity_jid, name): | 109 def getCache(self, client, entity_jid, name): |
217 """return cached value for jid | 110 """return cached value for jid |
218 | 111 |
219 @param entity_jid(jid.JID): target contact | 112 @param entity_jid(jid.JID): target contact |
220 @param name(unicode): name of the value ('nick' or 'avatar') | 113 @param name(unicode): name of the value ('nick' or 'avatar') |
221 @return(unicode, None): wanted value or None""" | 114 @return(unicode, None): wanted value or None""" |
222 entity_jid = self.getBareOrFull(client, entity_jid) | 115 entity_jid = self._i.getIdentityJid(client, entity_jid) |
223 try: | 116 try: |
224 data = self.host.memory.getEntityData(entity_jid, [name], client.profile) | 117 data = self.host.memory.getEntityData(client, entity_jid, [name]) |
225 except exceptions.UnknownEntityError: | 118 except exceptions.UnknownEntityError: |
226 return None | 119 return None |
227 return data.get(name) | 120 return data.get(name) |
228 | 121 |
229 def savePhoto(self, client, photo_elt, entity_jid): | 122 def savePhoto(self, client, photo_elt, entity_jid): |
233 mime_type = str(next(photo_elt.elements(NS_VCARD, "TYPE"))) | 126 mime_type = str(next(photo_elt.elements(NS_VCARD, "TYPE"))) |
234 except StopIteration: | 127 except StopIteration: |
235 mime_type = None | 128 mime_type = None |
236 else: | 129 else: |
237 if not mime_type: | 130 if not mime_type: |
238 # MIME type not know, we'll only support PNG files | 131 # MIME type not known, we'll try autodetection below |
239 # TODO: autodetection using e.g. "magic" module | |
240 # (https://pypi.org/project/python-magic/) | |
241 mime_type = None | 132 mime_type = None |
242 elif mime_type not in ("image/gif", "image/jpeg", "image/png"): | 133 elif mime_type == "image/x-png": |
243 if mime_type == "image/x-png": | 134 # XXX: this old MIME type is still used by some clients |
244 # XXX: this old MIME type is still used by some clients | 135 mime_type = "image/png" |
245 mime_type = "image/png" | 136 |
246 else: | |
247 # TODO: handle other image formats (svg?) | |
248 log.warning( | |
249 "following avatar image format is not handled: {type} [{jid}]".format( | |
250 type=mime_type, jid=entity_jid.full() | |
251 ) | |
252 ) | |
253 raise Failure(exceptions.DataError()) | |
254 | |
255 ext = mimetypes.guess_extension(mime_type, strict=False) | |
256 assert ext is not None | |
257 if ext == ".jpe": | |
258 ext = ".jpg" | |
259 log.debug( | |
260 "photo of type {type} with extension {ext} found [{jid}]".format( | |
261 type=mime_type, ext=ext, jid=entity_jid.full() | |
262 ) | |
263 ) | |
264 try: | 137 try: |
265 buf = str(next(photo_elt.elements(NS_VCARD, "BINVAL"))) | 138 buf = str(next(photo_elt.elements(NS_VCARD, "BINVAL"))) |
266 except StopIteration: | 139 except StopIteration: |
267 log.warning("BINVAL element not found") | 140 log.warning("BINVAL element not found") |
268 raise Failure(exceptions.NotFound()) | 141 raise Failure(exceptions.NotFound()) |
142 | |
269 if not buf: | 143 if not buf: |
270 log.warning("empty avatar for {jid}".format(jid=entity_jid.full())) | 144 log.warning("empty avatar for {jid}".format(jid=entity_jid.full())) |
271 raise Failure(exceptions.NotFound()) | 145 raise Failure(exceptions.NotFound()) |
272 if mime_type is None: | |
273 log.warning(_("no MIME type found for {entity}'s avatar, assuming image/png") | |
274 .format(entity=entity_jid.full())) | |
275 if buf[:8] != b'\x89\x50\x4e\x47\x0d\x0a\x1a\x0a': | |
276 log.warning("this is not a PNG file, ignoring it") | |
277 raise Failure(exceptions.DataError()) | |
278 else: | |
279 mime_type = "image/png" | |
280 | 146 |
281 log.debug(_("Decoding binary")) | 147 log.debug(_("Decoding binary")) |
282 decoded = b64decode(buf) | 148 decoded = b64decode(buf) |
283 del buf | 149 del buf |
150 | |
151 if mime_type is None: | |
152 log.debug( | |
153 f"no media type found specified for {entity_jid}'s avatar, trying to " | |
154 f"guess") | |
155 | |
156 try: | |
157 mime_type = image.guess_type(io.BytesIO(decoded)) | |
158 except IOError as e: | |
159 log.warning(f"Can't open avatar buffer: {e}") | |
160 | |
161 if mime_type is None: | |
162 msg = f"Can't find media type for {entity_jid}'s avatar" | |
163 log.warning(msg) | |
164 raise Failure(exceptions.DataError(msg)) | |
165 | |
284 image_hash = sha1(decoded).hexdigest() | 166 image_hash = sha1(decoded).hexdigest() |
285 with client.cache.cacheData( | 167 with self.host.common_cache.cacheData( |
286 PLUGIN_INFO["import_name"], | 168 PLUGIN_INFO["import_name"], |
287 image_hash, | 169 image_hash, |
288 mime_type, | 170 mime_type, |
289 # we keep in cache for 1 year | |
290 MAX_AGE, | |
291 ) as f: | 171 ) as f: |
292 f.write(decoded) | 172 f.write(decoded) |
293 return image_hash | 173 return image_hash |
294 | 174 |
295 @defer.inlineCallbacks | 175 async def vCard2Dict(self, client, vcard_elt, entity_jid): |
296 def vCard2Dict(self, client, vcard, entity_jid): | 176 """Convert a VCard_elt to a dict, and save binaries""" |
297 """Convert a VCard to a dict, and save binaries""" | 177 log.debug(("parsing vcard_elt")) |
298 log.debug(("parsing vcard")) | |
299 vcard_dict = {} | 178 vcard_dict = {} |
300 | 179 |
301 for elem in vcard.elements(): | 180 for elem in vcard_elt.elements(): |
302 if elem.name == "FN": | 181 if elem.name == "FN": |
303 vcard_dict["fullname"] = str(elem) | 182 vcard_dict["fullname"] = str(elem) |
304 elif elem.name == "NICKNAME": | 183 elif elem.name == "NICKNAME": |
305 vcard_dict["nick"] = str(elem) | 184 nickname = vcard_dict["nickname"] = str(elem) |
306 self.updateCache(client, entity_jid, "nick", vcard_dict["nick"]) | 185 await self._i.update( |
186 client, | |
187 "nicknames", | |
188 [nickname], | |
189 entity_jid | |
190 ) | |
307 elif elem.name == "URL": | 191 elif elem.name == "URL": |
308 vcard_dict["website"] = str(elem) | 192 vcard_dict["website"] = str(elem) |
309 elif elem.name == "EMAIL": | 193 elif elem.name == "EMAIL": |
310 vcard_dict["email"] = str(elem) | 194 vcard_dict["email"] = str(elem) |
311 elif elem.name == "BDAY": | 195 elif elem.name == "BDAY": |
312 vcard_dict["birthday"] = str(elem) | 196 vcard_dict["birthday"] = str(elem) |
313 elif elem.name == "PHOTO": | 197 elif elem.name == "PHOTO": |
314 # TODO: handle EXTVAL | 198 # TODO: handle EXTVAL |
315 try: | 199 try: |
316 avatar_hash = yield threads.deferToThread( | 200 avatar_hash = await threads.deferToThread( |
317 self.savePhoto, client, elem, entity_jid | 201 self.savePhoto, client, elem, entity_jid |
318 ) | 202 ) |
319 except (exceptions.DataError, exceptions.NotFound): | 203 except (exceptions.DataError, exceptions.NotFound): |
320 avatar_hash = "" | 204 avatar_hash = "" |
321 vcard_dict["avatar"] = avatar_hash | 205 vcard_dict["avatar"] = avatar_hash |
322 except Exception as e: | 206 except Exception as e: |
323 log.error("avatar saving error: {}".format(e)) | 207 log.error(f"avatar saving error: {e}") |
324 avatar_hash = None | 208 avatar_hash = None |
325 else: | 209 else: |
326 vcard_dict["avatar"] = avatar_hash | 210 vcard_dict["avatar"] = avatar_hash |
327 self.updateCache(client, entity_jid, "avatar", avatar_hash) | 211 if avatar_hash is not None: |
212 await client._xep_0054_avatar_hashes.aset( | |
213 entity_jid.full(), avatar_hash) | |
214 | |
215 if avatar_hash: | |
216 avatar_cache = self.host.common_cache.getMetadata(avatar_hash) | |
217 await self._i.update( | |
218 client, | |
219 "avatar", | |
220 { | |
221 'path': avatar_cache['path'], | |
222 'media_type': avatar_cache['mime_type'], | |
223 'cache_uid': avatar_hash | |
224 }, | |
225 entity_jid | |
226 ) | |
227 else: | |
228 await self._i.update(client, "avatar", None, entity_jid) | |
328 else: | 229 else: |
329 log.debug("FIXME: [{}] VCard tag is not managed yet".format(elem.name)) | 230 log.debug("FIXME: [{}] VCard_elt tag is not managed yet".format(elem.name)) |
330 | 231 |
331 # if a data in cache doesn't exist anymore, we need to delete it | 232 return vcard_dict |
332 # so we check CACHED_DATA no gotten (i.e. not in vcard_dict keys) | 233 |
333 # and we reset them | 234 async def getVCardElement(self, client, entity_jid): |
334 for datum in CACHED_DATA.difference(list(vcard_dict.keys())): | 235 """Retrieve domish.Element of a VCard |
335 log.debug( | 236 |
336 "reseting vcard datum [{datum}] for {entity}".format( | 237 @param entity_jid(jid.JID): entity from who we need the vCard |
337 datum=datum, entity=entity_jid.full() | 238 @raise DataError: we got an invalid answer |
338 ) | 239 """ |
339 ) | 240 iq_elt = client.IQ("get") |
340 self.updateCache(client, entity_jid, datum, None) | 241 iq_elt["from"] = client.jid.full() |
341 | 242 iq_elt["to"] = entity_jid.full() |
342 defer.returnValue(vcard_dict) | 243 iq_elt.addElement("vCard", NS_VCARD) |
343 | 244 iq_ret_elt = await iq_elt.send(entity_jid.full()) |
344 def _vCardCb(self, vcard_elt, to_jid, client): | 245 try: |
345 """Called after the first get IQ""" | 246 return next(iq_ret_elt.elements(NS_VCARD, "vCard")) |
346 log.debug(_("VCard found")) | 247 except StopIteration: |
347 iq_elt = vcard_elt.parent | 248 log.warning(_( |
348 try: | 249 "vCard element not found for {entity_jid}: {xml}" |
349 from_jid = jid.JID(iq_elt["from"]) | 250 ).format(entity_jid=entity_jid, xml=iq_ret_elt.toXml())) |
350 except KeyError: | 251 raise exceptions.DataError(f"no vCard element found for {entity_jid}") |
351 from_jid = client.jid.userhostJID() | 252 |
352 d = self.vCard2Dict(client, vcard_elt, from_jid) | 253 async def updateVCardElt(self, client, entity_jid, to_replace): |
353 return d | 254 """Create a vcard element to replace some metadata |
354 | 255 |
355 def _vCardEb(self, failure_, to_jid, client): | 256 @param to_replace(list[str]): list of vcard element names to remove |
356 """Called when something is wrong with registration""" | 257 """ |
357 log.warning( | 258 try: |
358 "Can't get vCard for {jid}: {failure}".format( | 259 # we first check if a vcard already exists, to keep data |
359 jid=to_jid.full, failure=failure_ | 260 vcard_elt = await self.getVCardElement(client, entity_jid) |
360 ) | |
361 ) | |
362 self.updateCache(client, to_jid, "avatar", None) | |
363 | |
364 def _getVcardElt(self, iq_elt): | |
365 return next(iq_elt.elements(NS_VCARD, "vCard")) | |
366 | |
367 def getCardRaw(self, client, entity_jid): | |
368 """get raw vCard XML | |
369 | |
370 params are as in [getCard] | |
371 """ | |
372 entity_jid = self.getBareOrFull(client, entity_jid) | |
373 log.debug("Asking for {}'s VCard".format(entity_jid.full())) | |
374 reg_request = client.IQ("get") | |
375 reg_request["from"] = client.jid.full() | |
376 reg_request["to"] = entity_jid.full() | |
377 reg_request.addElement("vCard", NS_VCARD) | |
378 d = reg_request.send(entity_jid.full()) | |
379 d.addCallback(self._getVcardElt) | |
380 return d | |
381 | |
382 def getCard(self, client, entity_jid): | |
383 """Ask server for VCard | |
384 | |
385 @param entity_jid(jid.JID): jid from which we want the VCard | |
386 @result: id to retrieve the profile | |
387 """ | |
388 d = self.getCardRaw(client, entity_jid) | |
389 d.addCallbacks( | |
390 self._vCardCb, | |
391 self._vCardEb, | |
392 callbackArgs=[entity_jid, client], | |
393 errbackArgs=[entity_jid, client], | |
394 ) | |
395 return d | |
396 | |
397 def _getCardCb(self, __, client, entity): | |
398 try: | |
399 return client._cache_0054[entity.full()]["avatar"] | |
400 except KeyError: | |
401 raise Failure(exceptions.NotFound()) | |
402 | |
403 def _getAvatar(self, entity, cache_only, hash_only, profile): | |
404 client = self.host.getClient(profile) | |
405 d = self.getAvatar(client, jid.JID(entity), cache_only, hash_only) | |
406 # we need to convert the Path to string | |
407 d.addCallback(str) | |
408 d.addErrback(lambda __: "") | |
409 return d | |
410 | |
411 def getAvatar(self, client, entity, cache_only=True, hash_only=False): | |
412 """get avatar full path or hash | |
413 | |
414 if avatar is not in local cache, it will be requested to the server | |
415 @param entity(jid.JID): entity to get avatar from | |
416 @param cache_only(bool): if False, will request vCard if avatar is | |
417 not in cache | |
418 @param hash_only(bool): if True only return hash, not full path | |
419 @raise exceptions.NotFound: no avatar found | |
420 """ | |
421 if not entity.resource and self.isRoom(client, entity): | |
422 raise exceptions.NotFound | |
423 entity = self.getBareOrFull(client, entity) | |
424 full_path = None | |
425 | |
426 try: | |
427 # we first check if we have avatar in cache | |
428 avatar_hash = client._cache_0054[entity.full()]["avatar"] | |
429 if avatar_hash: | |
430 # avatar is known and exists | |
431 full_path = client.cache.getFilePath(avatar_hash) | |
432 if full_path is None: | |
433 # cache file is not available (probably expired) | |
434 raise KeyError | |
435 else: | |
436 # avatar has already been checked but it is not set | |
437 full_path = "" | |
438 except KeyError: | |
439 # avatar is not in cache | |
440 if cache_only: | |
441 return defer.fail(Failure(exceptions.NotFound())) | |
442 # we request vCard to get avatar | |
443 d = self.getCard(client, entity) | |
444 d.addCallback(self._getCardCb, client, entity) | |
445 else: | |
446 # avatar is in cache, we can return hash | |
447 d = defer.succeed(avatar_hash) | |
448 | |
449 if not hash_only: | |
450 # full path is requested | |
451 if full_path is None: | |
452 d.addCallback(client.cache.getFilePath) | |
453 else: | |
454 d.addCallback(lambda __: full_path) | |
455 return d | |
456 | |
457 @defer.inlineCallbacks | |
458 def getNick(self, client, entity): | |
459 """get nick from cache, or check vCard | |
460 | |
461 @param entity(jid.JID): entity to get nick from | |
462 @return(unicode, None): nick or None if not found | |
463 """ | |
464 nick = self.getCache(client, entity, "nick") | |
465 if nick is not None: | |
466 defer.returnValue(nick) | |
467 yield self.getCard(client, entity) | |
468 defer.returnValue(self.getCache(client, entity, "nick")) | |
469 | |
470 @defer.inlineCallbacks | |
471 def setNick(self, client, nick): | |
472 """update our vCard and set a nickname | |
473 | |
474 @param nick(unicode): new nickname to use | |
475 """ | |
476 jid_ = client.jid.userhostJID() | |
477 try: | |
478 vcard_elt = yield self.getCardRaw(client, jid_) | |
479 except error.StanzaError as e: | 261 except error.StanzaError as e: |
480 if e.condition == "item-not-found": | 262 if e.condition == "item-not-found": |
481 vcard_elt = domish.Element((NS_VCARD, "vCard")) | 263 vcard_elt = domish.Element((NS_VCARD, "vCard")) |
482 else: | 264 else: |
483 raise e | 265 raise e |
484 try: | 266 except exceptions.DataError: |
485 nickname_elt = next(vcard_elt.elements(NS_VCARD, "NICKNAME")) | 267 vcard_elt = domish.Element((NS_VCARD, "vCard")) |
486 except StopIteration: | |
487 pass | |
488 else: | 268 else: |
489 vcard_elt.children.remove(nickname_elt) | 269 # the vcard exists, we need to remove elements that we'll replace |
490 | 270 for elt_name in to_replace: |
491 nickname_elt = vcard_elt.addElement((NS_VCARD, "NICKNAME"), content=nick) | 271 try: |
492 iq_elt = client.IQ() | 272 elt = next(vcard_elt.elements(NS_VCARD, elt_name)) |
493 vcard_elt = iq_elt.addChild(vcard_elt) | 273 except StopIteration: |
494 yield iq_elt.send() | 274 pass |
495 self.updateCache(client, jid_, "nick", str(nick)) | 275 else: |
496 | 276 vcard_elt.children.remove(elt) |
497 def _buildSetAvatar(self, client, vcard_elt, file_path): | 277 |
278 return vcard_elt | |
279 | |
280 async def getCard(self, client, entity_jid): | |
281 """Ask server for VCard | |
282 | |
283 @param entity_jid(jid.JID): jid from which we want the VCard | |
284 @result(dict): vCard data | |
285 """ | |
286 entity_jid = self._i.getIdentityJid(client, entity_jid) | |
287 log.debug(f"Asking for {entity_jid}'s VCard") | |
288 try: | |
289 vcard_elt = await self.getVCardElement(client, entity_jid) | |
290 except exceptions.DataError: | |
291 self._i.update(client, "avatar", None, entity_jid) | |
292 except Exception as e: | |
293 log.warning(_( | |
294 "Can't get vCard for {entity_jid}: {e}" | |
295 ).format(entity_jid=entity_jid, e=e)) | |
296 else: | |
297 log.debug(_("VCard found")) | |
298 return await self.vCard2Dict(client, vcard_elt, entity_jid) | |
299 | |
300 async def getAvatar(self, client, entity_jid): | |
301 """Get avatar full path or hash | |
302 | |
303 if avatar is not in local cache, it will be requested to the server | |
304 @param entity(jid.JID): entity to get avatar from | |
305 | |
306 """ | |
307 entity_jid = self._i.getIdentityJid(client, entity_jid) | |
308 hashes_cache = client._xep_0054_avatar_hashes | |
309 try: | |
310 avatar_hash = hashes_cache[entity_jid.full()] | |
311 except KeyError: | |
312 log.debug(f"avatar for {entity_jid} is not in cache, we retrieve it") | |
313 vcard = await self.getCard(client, entity_jid) | |
314 if vcard is None: | |
315 return None | |
316 avatar_hash = hashes_cache[entity_jid.full()] | |
317 | |
318 if not avatar_hash: | |
319 return None | |
320 | |
321 avatar_cache = self.host.common_cache.getMetadata(avatar_hash) | |
322 if avatar_cache is None: | |
323 log.debug("avatar is no more in cache, we re-download it") | |
324 vcard = await self.getCard(client, entity_jid) | |
325 if vcard is None: | |
326 return None | |
327 avatar_cache = self.host.common_cache.getMetadata(avatar_hash) | |
328 | |
329 return self._i.avatarBuildMetadata( | |
330 avatar_cache['path'], avatar_cache['mime_type'], avatar_hash) | |
331 | |
332 def _buildSetAvatar(self, client, vcard_elt, avatar_data): | |
498 # XXX: this method is executed in a separate thread | 333 # XXX: this method is executed in a separate thread |
499 try: | 334 try: |
500 img = Image.open(file_path) | 335 img = Image.open(avatar_data['path']) |
501 except IOError: | 336 except IOError as e: |
502 return Failure(exceptions.DataError("Can't open image")) | 337 raise exceptions.DataError(f"Can't open image: {e}") |
503 | 338 |
504 if img.size != AVATAR_DIM: | 339 if img.size != AVATAR_DIM: |
505 img.thumbnail(AVATAR_DIM) | 340 img.thumbnail(AVATAR_DIM) |
506 if img.size[0] != img.size[1]: # we need to crop first | 341 if img.size[0] != img.size[1]: # we need to crop first |
507 left, upper = (0, 0) | 342 left, upper = (0, 0) |
517 img_buf = io.BytesIO() | 352 img_buf = io.BytesIO() |
518 img.save(img_buf, "PNG") | 353 img.save(img_buf, "PNG") |
519 | 354 |
520 photo_elt = vcard_elt.addElement("PHOTO") | 355 photo_elt = vcard_elt.addElement("PHOTO") |
521 photo_elt.addElement("TYPE", content="image/png") | 356 photo_elt.addElement("TYPE", content="image/png") |
522 image_b64 = b64encode(img_buf.getvalue()).decode('utf-8') | 357 image_b64 = b64encode(img_buf.getvalue()).decode() |
523 photo_elt.addElement("BINVAL", content=image_b64) | 358 photo_elt.addElement("BINVAL", content=image_b64) |
524 image_hash = sha1(img_buf.getvalue()).hexdigest() | 359 image_hash = sha1(img_buf.getvalue()).hexdigest() |
525 with client.cache.cacheData( | 360 with self.host.common_cache.cacheData( |
526 PLUGIN_INFO["import_name"], image_hash, "image/png", MAX_AGE | 361 PLUGIN_INFO["import_name"], image_hash, "image/png" |
527 ) as f: | 362 ) as f: |
528 f.write(img_buf.getvalue()) | 363 f.write(img_buf.getvalue()) |
364 avatar_data['path'] = Path(f.name) | |
365 avatar_data['cache_uid'] = image_hash | |
529 return image_hash | 366 return image_hash |
530 | 367 |
531 def _setAvatar(self, file_path, profile_key=C.PROF_KEY_NONE): | 368 async def setAvatar(self, client, avatar_data, entity): |
532 client = self.host.getClient(profile_key) | |
533 return self.setAvatar(client, file_path) | |
534 | |
535 @defer.inlineCallbacks | |
536 def setAvatar(self, client, file_path): | |
537 """Set avatar of the profile | 369 """Set avatar of the profile |
538 | 370 |
539 @param file_path: path of the image of the avatar | 371 @param avatar_data(dict): data of the image to use as avatar, as built by |
540 """ | 372 IDENTITY plugin. |
541 try: | 373 @param entity(jid.JID): entity whose avatar must be changed |
542 # we first check if a vcard already exists, to keep data | 374 """ |
543 vcard_elt = yield self.getCardRaw(client, client.jid.userhostJID()) | 375 vcard_elt = await self.updateVCardElt(client, entity, ['PHOTO']) |
544 except error.StanzaError as e: | |
545 if e.condition == "item-not-found": | |
546 vcard_elt = domish.Element((NS_VCARD, "vCard")) | |
547 else: | |
548 raise e | |
549 else: | |
550 # the vcard exists, we need to remove PHOTO element as we'll make a new one | |
551 try: | |
552 photo_elt = next(vcard_elt.elements(NS_VCARD, "PHOTO")) | |
553 except StopIteration: | |
554 pass | |
555 else: | |
556 vcard_elt.children.remove(photo_elt) | |
557 | 376 |
558 iq_elt = client.IQ() | 377 iq_elt = client.IQ() |
559 iq_elt.addChild(vcard_elt) | 378 iq_elt.addChild(vcard_elt) |
560 image_hash = yield threads.deferToThread( | 379 await threads.deferToThread( |
561 self._buildSetAvatar, client, vcard_elt, file_path | 380 self._buildSetAvatar, client, vcard_elt, avatar_data |
562 ) | 381 ) |
563 # image is now at the right size/format | 382 # image is now at the right size/format |
564 | 383 |
565 self.updateCache(client, client.jid.userhostJID(), "avatar", image_hash) | 384 await iq_elt.send() |
566 yield iq_elt.send() | 385 |
567 client.presence.available() # FIXME: should send the current presence, not always "available" ! | 386 # FIXME: should send the current presence, not always "available" ! |
387 await client.presence.available() | |
388 | |
389 async def getNicknames(self, client, entity): | |
390 """get nick from cache, or check vCard | |
391 | |
392 @param entity(jid.JID): entity to get nick from | |
393 @return(list[str]): nicknames found | |
394 """ | |
395 vcard_data = await self.getCard(client, entity) | |
396 try: | |
397 return [vcard_data['nickname']] | |
398 except (KeyError, TypeError): | |
399 return [] | |
400 | |
401 async def setNicknames(self, client, nicknames, entity): | |
402 """Update our vCard and set a nickname | |
403 | |
404 @param nicknames(list[str]): new nicknames to use | |
405 only first item of this list will be used here | |
406 """ | |
407 nick = nicknames[0].strip() | |
408 | |
409 vcard_elt = await self.updateVCardElt(client, entity, ['NICKNAME']) | |
410 | |
411 if nick: | |
412 vcard_elt.addElement((NS_VCARD, "NICKNAME"), content=nick) | |
413 iq_elt = client.IQ() | |
414 iq_elt.addChild(vcard_elt) | |
415 await iq_elt.send() | |
568 | 416 |
569 | 417 |
570 @implementer(iwokkel.IDisco) | 418 @implementer(iwokkel.IDisco) |
571 class XEP_0054_handler(XMPPHandler): | 419 class XEP_0054_handler(XMPPHandler): |
572 | 420 |
573 def __init__(self, plugin_parent): | 421 def __init__(self, plugin_parent): |
574 self.plugin_parent = plugin_parent | 422 self.plugin_parent = plugin_parent |
575 self.host = plugin_parent.host | 423 self.host = plugin_parent.host |
576 | 424 |
577 def connectionInitialized(self): | 425 def connectionInitialized(self): |
578 self.xmlstream.addObserver(VCARD_UPDATE, self.update) | 426 self.xmlstream.addObserver(VCARD_UPDATE, self._update) |
579 | 427 |
580 def getDiscoInfo(self, requestor, target, nodeIdentifier=""): | 428 def getDiscoInfo(self, requestor, target, nodeIdentifier=""): |
581 return [disco.DiscoFeature(NS_VCARD)] | 429 return [disco.DiscoFeature(NS_VCARD)] |
582 | 430 |
583 def getDiscoItems(self, requestor, target, nodeIdentifier=""): | 431 def getDiscoItems(self, requestor, target, nodeIdentifier=""): |
584 return [] | 432 return [] |
585 | 433 |
586 def _checkAvatarHash(self, __, client, entity, given_hash): | 434 def _checkAvatarHash(self, client, entity, given_hash): |
587 """check that hash in cash (i.e. computed hash) is the same as given one""" | 435 """Check that hash in cache (i.e. computed hash) is the same as given one""" |
588 # XXX: if they differ, the avater will be requested on each connection | 436 # XXX: if they differ, the avatar will be requested on each connection |
589 # TODO: try to avoid re-requesting avatar in this case | 437 # TODO: try to avoid re-requesting avatar in this case |
590 computed_hash = self.plugin_parent.getCache(client, entity, "avatar") | 438 computed_hash = client._xep_0054_avatar_hashes[entity.full] |
591 if computed_hash != given_hash: | 439 if computed_hash != given_hash: |
592 log.warning( | 440 log.warning( |
593 "computed hash differs from given hash for {entity}:\n" | 441 "computed hash differs from given hash for {entity}:\n" |
594 "computed: {computed}\ngiven: {given}".format( | 442 "computed: {computed}\ngiven: {given}".format( |
595 entity=entity, computed=computed_hash, given=given_hash | 443 entity=entity, computed=computed_hash, given=given_hash |
596 ) | 444 ) |
597 ) | 445 ) |
598 | 446 |
599 def update(self, presence): | 447 async def update(self, presence): |
600 """Called on <presence/> stanza with vcard data | 448 """Called on <presence/> stanza with vcard data |
601 | 449 |
602 Check for avatar information, and get VCard if needed | 450 Check for avatar information, and get VCard if needed |
603 @param presend(domish.Element): <presence/> stanza | 451 @param presence(domish.Element): <presence/> stanza |
604 """ | 452 """ |
605 client = self.parent | 453 client = self.parent |
606 entity_jid = self.plugin_parent.getBareOrFull(client, jid.JID(presence["from"])) | 454 entity_jid = self.plugin_parent._i.getIdentityJid( |
607 # FIXME: wokkel's data_form should be used here | 455 client, jid.JID(presence["from"])) |
456 | |
608 try: | 457 try: |
609 x_elt = next(presence.elements(NS_VCARD_UPDATE, "x")) | 458 x_elt = next(presence.elements(NS_VCARD_UPDATE, "x")) |
610 except StopIteration: | 459 except StopIteration: |
611 return | 460 return |
612 | 461 |
613 try: | 462 try: |
614 photo_elt = next(x_elt.elements(NS_VCARD_UPDATE, "photo")) | 463 photo_elt = next(x_elt.elements(NS_VCARD_UPDATE, "photo")) |
615 except StopIteration: | 464 except StopIteration: |
616 return | 465 return |
617 | 466 |
618 hash_ = str(photo_elt).strip() | 467 new_hash = str(photo_elt).strip() |
619 if hash_ == C.HASH_SHA1_EMPTY: | 468 if new_hash == HASH_SHA1_EMPTY: |
620 hash_ = "" | 469 new_hash = "" |
621 old_avatar = self.plugin_parent.getCache(client, entity_jid, "avatar") | 470 |
622 | 471 hashes_cache = client._xep_0054_avatar_hashes |
623 if old_avatar == hash_: | 472 |
624 # no change, we can return... | 473 old_hash = hashes_cache.get(entity_jid.full()) |
625 if hash_: | 474 |
626 # ...but we double check that avatar is in cache | 475 if old_hash == new_hash: |
627 file_path = client.cache.getFilePath(hash_) | 476 # no change, we can return… |
628 if file_path is None: | 477 if new_hash: |
629 log.error( | 478 # …but we double check that avatar is in cache |
630 "Avatar for [{}] should be in cache but it is not! We get it".format( | 479 avatar_cache = self.host.common_cache.getMetadata(new_hash) |
631 entity_jid.full() | 480 if avatar_cache is None: |
632 ) | 481 log.debug( |
482 f"Avatar for [{entity_jid}] is known but not in cache, we get " | |
483 f"it" | |
633 ) | 484 ) |
634 self.plugin_parent.getCard(client, entity_jid) | 485 # getCard will put the avatar in cache |
635 else: | 486 await self.plugin_parent.getCard(client, entity_jid) |
636 log.debug("avatar for {} already in cache".format(entity_jid.full())) | 487 else: |
488 log.debug(f"avatar for {entity_jid} is already in cache") | |
637 return | 489 return |
638 | 490 |
639 if not hash_: | 491 if new_hash is None: |
640 # the avatar has been removed | 492 # XXX: we use empty string to indicate that there is no avatar |
641 # XXX: we use empty string instead of None to indicate that we took avatar | 493 new_hash = "" |
642 # but it is empty on purpose | 494 |
643 self.plugin_parent.updateCache(client, entity_jid, "avatar", "") | 495 await hashes_cache.aset(entity_jid.full(), new_hash) |
496 | |
497 if not new_hash: | |
498 await self.plugin_parent._i.update( | |
499 client, "avatar", None, entity_jid) | |
500 # the avatar has been removed, no need to go further | |
644 return | 501 return |
645 | 502 |
646 file_path = client.cache.getFilePath(hash_) | 503 avatar_cache = self.host.common_cache.getMetadata(new_hash) |
647 if file_path is not None: | 504 if avatar_cache is not None: |
648 log.debug( | 505 log.debug( |
649 "New avatar found for [{}], it's already in cache, we use it".format( | 506 f"New avatar found for [{entity_jid}], it's already in cache, we use it" |
650 entity_jid.full() | |
651 ) | |
652 ) | 507 ) |
653 self.plugin_parent.updateCache(client, entity_jid, "avatar", hash_) | 508 await self.plugin_parent._i.update( |
509 client, | |
510 "avatar", | |
511 { | |
512 'path': avatar_cache['path'], | |
513 'media_type': avatar_cache['mime_type'], | |
514 'cache_uid': new_hash, | |
515 }, | |
516 entity_jid | |
517 ) | |
654 else: | 518 else: |
655 log.debug( | 519 log.debug( |
656 "New avatar found for [{}], requesting vcard".format(entity_jid.full()) | 520 "New avatar found for [{entity_jid}], requesting vcard" |
657 ) | 521 ) |
658 d = self.plugin_parent.getCard(client, entity_jid) | 522 vcard = await self.plugin_parent.getCard(client, entity_jid) |
659 d.addCallback(self._checkAvatarHash, client, entity_jid, hash_) | 523 if vcard is None: |
524 log.warning(f"Unexpected empty vCard for {entity_jid}") | |
525 return | |
526 await self._checkAvatarHash(client, entity_jid, new_hash) | |
527 | |
528 def _update(self, presence): | |
529 defer.ensureDeferred(self.update(presence)) |