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))