comparison src/plugins/plugin_xep_0054.py @ 2123:c42aab22c2c0

plugin XEP-0054, quick frontend(app): various improvments: - memory.cache is now used - getAvatar and setAvatar has been renamed to avatarGet and avatarSet to follow new convention - getAvatar now return (optionally) full path, and may request vCard or only return avatar in cache - getAvatarFile has been removed as it is not needed anymore (avatarGet can return full path) - getCard has been removed from bridge as it was only used to request avatar - new internal method getBareOfFull return jid to use depending of the jid being part of a MUC or not - cache is now set per profile in client, instead of a general one for all profiles - thanks to the use of memory.cache, correct extension is now used in saved file, according to MIME type - fixed and better cache handling - a warning message is shown if given avatar hash differs from computed one - empty hash value is now in a constant, and ignored if received - QuickApp has been updated to follow new behaviour - Primitivus has been fixed (it was not declaring not using avatars correclty) - jp has been updated to follow new methods name
author Goffi <goffi@goffi.org>
date Sun, 15 Jan 2017 17:51:37 +0100
parents 11fb5f5e2f89
children 1d3f73e065e1
comparison
equal deleted inserted replaced
2122:3970ebcf8830 2123:c42aab22c2c0
22 from sat.core.constants import Const as C 22 from sat.core.constants import Const as C
23 from sat.core.log import getLogger 23 from sat.core.log import getLogger
24 log = getLogger(__name__) 24 log = getLogger(__name__)
25 from twisted.internet import threads, defer 25 from twisted.internet import threads, defer
26 from twisted.words.protocols.jabber import jid 26 from twisted.words.protocols.jabber import jid
27 from twisted.words.protocols.jabber.xmlstream import IQ
28 from twisted.words.xish import domish 27 from twisted.words.xish import domish
29 from twisted.python.failure import Failure 28 from twisted.python.failure import Failure
30 import os.path
31 29
32 from zope.interface import implements 30 from zope.interface import implements
33 31
34 from wokkel import disco, iwokkel 32 from wokkel import disco, iwokkel
35 33
36 from base64 import b64decode, b64encode 34 from base64 import b64decode, b64encode
37 from hashlib import sha1 35 from hashlib import sha1
38 from sat.core import exceptions 36 from sat.core import exceptions
39 from sat.memory import persistent 37 from sat.memory import persistent
38 import mimetypes
40 try: 39 try:
41 from PIL import Image 40 from PIL import Image
42 except: 41 except:
43 raise exceptions.MissingModule(u"Missing module pillow, please download/install it from https://python-pillow.github.io") 42 raise exceptions.MissingModule(u"Missing module pillow, please download/install it from https://python-pillow.github.io")
44 from cStringIO import StringIO 43 from cStringIO import StringIO
47 from twisted.words.protocols.xmlstream import XMPPHandler 46 from twisted.words.protocols.xmlstream import XMPPHandler
48 except ImportError: 47 except ImportError:
49 from wokkel.subprotocols import XMPPHandler 48 from wokkel.subprotocols import XMPPHandler
50 49
51 AVATAR_PATH = "avatars" 50 AVATAR_PATH = "avatars"
52 AVATAR_DIM = (64, 64) 51 AVATAR_DIM = (64, 64) # FIXME: dim are not adapted to modern resolutions !
53 52
54 IQ_GET = '/iq[@type="get"]' 53 IQ_GET = '/iq[@type="get"]'
55 NS_VCARD = 'vcard-temp' 54 NS_VCARD = 'vcard-temp'
56 VCARD_REQUEST = IQ_GET + '/vCard[@xmlns="' + NS_VCARD + '"]' # TODO: manage requests 55 VCARD_REQUEST = IQ_GET + '/vCard[@xmlns="' + NS_VCARD + '"]' # TODO: manage requests
57 56
58 PRESENCE = '/presence' 57 PRESENCE = '/presence'
59 NS_VCARD_UPDATE = 'vcard-temp:x:update' 58 NS_VCARD_UPDATE = 'vcard-temp:x:update'
60 VCARD_UPDATE = PRESENCE + '/x[@xmlns="' + NS_VCARD_UPDATE + '"]' 59 VCARD_UPDATE = PRESENCE + '/x[@xmlns="' + NS_VCARD_UPDATE + '"]'
61 60
62 CACHED_DATA = {'avatar', 'nick'} 61 CACHED_DATA = {'avatar', 'nick'}
62 MAX_AGE = 60 * 60 * 24 * 365
63 63
64 PLUGIN_INFO = { 64 PLUGIN_INFO = {
65 "name": "XEP 0054 Plugin", 65 "name": "XEP 0054 Plugin",
66 "import_name": "XEP-0054", 66 "import_name": "XEP-0054",
67 "type": "XEP", 67 "type": "XEP",
78 #TODO: - check that nickname is ok 78 #TODO: - check that nickname is ok
79 # - refactor the code/better use of Wokkel 79 # - refactor the code/better use of Wokkel
80 # - get missing values 80 # - get missing values
81 81
82 def __init__(self, host): 82 def __init__(self, host):
83 log.info(_("Plugin XEP_0054 initialization")) 83 log.info(_(u"Plugin XEP_0054 initialization"))
84 self.host = host 84 self.host = host
85 self.avatar_path = os.path.join(self.host.memory.getConfig('', 'local_dir'), AVATAR_PATH) 85 host.bridge.addMethod(u"avatarGet", u".plugin", in_sign=u'sbbs', out_sign=u's', method=self._getAvatar, async=True)
86 if not os.path.exists(self.avatar_path): 86 host.bridge.addMethod(u"avatarSet", u".plugin", in_sign=u'ss', out_sign=u'', method=self._setAvatar, async=True)
87 os.makedirs(self.avatar_path) 87 host.trigger.add(u"presence_available", self.presenceAvailableTrigger)
88 self.cache = {} 88 host.memory.setSignalOnUpdate(u"avatar")
89 host.bridge.addMethod("getCard", ".plugin", in_sign='ss', out_sign='', method=self._getCard, async=True) 89 host.memory.setSignalOnUpdate(u"nick")
90 host.bridge.addMethod("getAvatarFile", ".plugin", in_sign='s', out_sign='s', method=self.getAvatarFile)
91 host.bridge.addMethod("getAvatar", ".plugin", in_sign='ss', out_sign='s', method=self._getAvatar, async=True)
92 host.bridge.addMethod("setAvatar", ".plugin", in_sign='ss', out_sign='', method=self.setAvatar, async=True)
93 host.trigger.add("presence_available", self.presenceAvailableTrigger)
94 host.memory.setSignalOnUpdate("avatar")
95 host.memory.setSignalOnUpdate("nick")
96 90
97 def getHandler(self, profile): 91 def getHandler(self, profile):
98 return XEP_0054_handler(self) 92 return XEP_0054_handler(self)
99 93
94 def isRoom(self, client, entity_jid):
95 """Tell if a jid is a MUC one
96
97 @param entity_jid(jid.JID): full or bare jid of the entity check
98 @return (bool): True if the bare jid of the entity is a room jid
99 """
100 try:
101 self.host.plugins['XEP-0045'].checkRoomJoined(client, entity_jid.userhostJID())
102 except exceptions.NotFound:
103 return False
104 else:
105 return True
106
107 def getBareOrFull(self, client, jid_):
108 """use full jid if jid_ is an occupant of a room, bare jid else
109
110 @param jid_(jid.JID): entity to test
111 @return (jid.JID): bare or full jid
112 """
113 if jid_.resource:
114 if not self.isRoom(client, jid_):
115 return jid_.userhostJID()
116 return jid_
117
100 def presenceAvailableTrigger(self, presence_elt, client): 118 def presenceAvailableTrigger(self, presence_elt, client):
101 if client.jid.userhost() in self.cache[client.profile]: 119 if client.jid.userhost() in client._cache_0054:
102 try: 120 try:
103 avatar_hash = self.cache[client.profile][client.jid.userhost()]['avatar'] 121 avatar_hash = client._cache_0054[client.jid.userhost()]['avatar']
104 except KeyError: 122 except KeyError:
105 log.info(u"No avatar in cache for {}".format(client.jid.userhost())) 123 log.info(u"No avatar in cache for {}".format(client.jid.userhost()))
106 return True 124 return True
107 x_elt = domish.Element((NS_VCARD_UPDATE, 'x')) 125 x_elt = domish.Element((NS_VCARD_UPDATE, 'x'))
108 x_elt.addElement('photo', content=avatar_hash) 126 x_elt.addElement('photo', content=avatar_hash)
109 presence_elt.addChild(x_elt) 127 presence_elt.addChild(x_elt)
110
111 return True 128 return True
112 129
113 def isRoom(self, client, entity_jid): 130 @defer.inlineCallbacks
114 """Tell if a jid is a MUC one 131 def profileConnecting(self, profile):
115 132 client = self.host.getClient(profile)
116 @param entity_jid(jid.JID): full or bare jid of the entity check 133 client._cache_0054 = persistent.PersistentBinaryDict(NS_VCARD, profile)
117 @return (bool): True if the bare jid of the entity is a room jid 134 yield client._cache_0054.load()
118 """ 135 self._fillCachedValues(profile)
119 try:
120 self.host.plugins['XEP-0045'].checkRoomJoined(client, entity_jid.userhostJID())
121 except exceptions.NotFound:
122 return False
123 else:
124 return True
125 136
126 def _fillCachedValues(self, profile): 137 def _fillCachedValues(self, profile):
127 #FIXME: this is really suboptimal, need to be reworked 138 #FIXME: this may need to be reworked
128 # the current naive approach keeps a map between all jids of all profiles 139 # the current naive approach keeps a map between all jids
129 # in persistent cache, then put avatar hashs in memory. 140 # in persistent cache, then put avatar hashs in memory.
130 # Hashes should be shared between profiles 141 # Hashes should be shared between profiles (or not ? what
131 for jid_s, data in self.cache[profile].iteritems(): 142 # if the avatar is different depending on who is requesting it
143 # this is not possible with vcard-tmp, but it is with XEP-0084).
144 # Loading avatar on demand per jid may be a option to investigate.
145 client = self.host.getClient(profile)
146 for jid_s, data in client._cache_0054.iteritems():
132 jid_ = jid.JID(jid_s) 147 jid_ = jid.JID(jid_s)
133 for name in CACHED_DATA: 148 for name in CACHED_DATA:
134 try: 149 try:
150 value = data[name]
151 if value is None:
152 log.error(u"{name} value for {jid_} is None, ignoring".format(name=name, jid_=jid_))
153 continue
135 self.host.memory.updateEntityData(jid_, name, data[name], silent=True, profile_key=profile) 154 self.host.memory.updateEntityData(jid_, name, data[name], silent=True, profile_key=profile)
136 except KeyError: 155 except KeyError:
137 pass 156 pass
138 157
139 @defer.inlineCallbacks
140 def profileConnecting(self, profile):
141 self.cache[profile] = persistent.PersistentBinaryDict(NS_VCARD, profile)
142 yield self.cache[profile].load()
143 self._fillCachedValues(profile)
144
145 def profileDisconnected(self, profile):
146 log.debug(u"Deleting profile cache for avatars")
147 del self.cache[profile]
148
149 def updateCache(self, client, jid_, name, value): 158 def updateCache(self, client, jid_, name, value):
150 """update cache value 159 """update cache value
151 160
152 save value in memory in case of change 161 save value in memory in case of change
153 @param jid_(jid.JID): jid of the owner of the vcard 162 @param jid_(jid.JID): jid of the owner of the vcard
154 @param name(str): name of the item which changed 163 @param name(str): name of the item which changed
155 @param value(unicode): new value of the item 164 @param value(unicode, None): new value of the item
156 """ 165 None to delete
157 if jid_.resource: 166 """
158 if not self.isRoom(client, jid_): 167 jid_ = self.getBareOrFull(client, jid_)
159 # VCard are retrieved with bare jid 168 jid_s = jid_.full()
160 # but MUC room is a special case 169
161 jid_ = jid.userhostJID() 170 if value is None:
162 171 try:
163 self.host.memory.updateEntityData(jid_, name, value, profile_key=client.profile) 172 self.host.memory.delEntityDatum(jid_, name, client.profile)
164 if name in CACHED_DATA: 173 except (KeyError, exceptions.UnknownEntityError):
165 jid_s = jid_.userhost() 174 pass
166 self.cache[client.profile].setdefault(jid_s, {})[name] = value 175 if name in CACHED_DATA:
167 self.cache[client.profile].force(jid_s) 176 try:
177 del client._cache_0054[jid_s][name]
178 except KeyError:
179 pass
180 else:
181 client._cache_0054.force(jid_s)
182 else:
183 self.host.memory.updateEntityData(jid_, name, value, profile_key=client.profile)
184 if name in CACHED_DATA:
185 client._cache_0054.setdefault(jid_s, {})[name] = value
186 client._cache_0054.force(jid_s)
168 187
169 def getCache(self, client, entity_jid, name): 188 def getCache(self, client, entity_jid, name):
170 """return cached value for jid 189 """return cached value for jid
171 190
172 @param entity_jid: target contact 191 @param entity_jid: target contact
173 @param name: name of the value ('nick' or 'avatar') 192 @param name: name of the value ('nick' or 'avatar')
174 @return: wanted value or None""" 193 @return: wanted value or None"""
175 if entity_jid.resource: 194 entity_jid = self.getBareOrFull(client, entity_jid)
176 if not self.isRoom(client, entity_jid):
177 # VCard are retrieved with bare jid
178 # but MUC room is a special case
179 entity_jid = jid.userhostJID()
180 try: 195 try:
181 data = self.host.memory.getEntityData(entity_jid, [name], client.profile) 196 data = self.host.memory.getEntityData(entity_jid, [name], client.profile)
182 except exceptions.UnknownEntityError: 197 except exceptions.UnknownEntityError:
183 return None 198 return None
184 return data.get(name) 199 return data.get(name)
185 200
186 def _getFilename(self, hash_): 201 def savePhoto(self, client, photo_elt, entity_jid):
187 """Get filename from hash 202 """Parse a <PHOTO> photo_elt and save the picture"""
188 203 # XXX: this method is launched in a separate thread
189 @param hash_: hash of the avatar 204 try:
190 @return (str): absolute filename of the avatar 205 mime_type = unicode(photo_elt.elements(NS_VCARD, 'TYPE').next())
191 """ 206 except StopIteration:
192 return os.path.join(self.avatar_path, hash_) 207 log.warning(u"no MIME type found, assuming image/png")
193 208 mime_type = u"image/png"
194 def saveAvatarFile(self, data, hash_):
195 """Save the avatar picture if it doesn't already exists
196
197 @param data(str): binary image of the avatar
198 @param hash_(str): hash of the binary data (will be used for the filename)
199 """
200 filename = self._getFilename(hash_)
201 if not os.path.exists(filename):
202 with open(filename, 'wb') as file_:
203 file_.write(data)
204 log.debug(_(u"file saved to %s") % hash_)
205 else: 209 else:
206 log.debug(_(u"file [%s] already in cache") % hash_) 210 if not mime_type:
207 211 log.warning(u"empty MIME type, assuming image/png")
208 def savePhoto(self, photo_elt, target): 212 mime_type = u"image/png"
209 """Parse a <PHOTO> photo_elt and save the picture""" 213 elif mime_type not in ("image/gif", "image/jpeg", "image/png"):
210 try: 214 if mime_type == "image/x-png":
211 type_ = unicode(photo_elt.elements(NS_VCARD, 'TYPE').next()) 215 # XXX: this old MIME type is still used by some clients
216 mime_type = "image/png"
217 else:
218 # TODO: handle other image formats (svg?)
219 log.warning(u"following avatar image format is not handled: {type} [{jid}]".format(
220 type=mime_type, jid=entity_jid.full()))
221 raise Failure(exceptions.DataError())
222
223 ext = mimetypes.guess_extension(mime_type, strict=False)
224 assert ext is not None
225 log.debug(u'photo of type {type} with extension {ext} found [{jid}]'.format(
226 type=mime_type, ext=ext, jid=entity_jid.full()))
227 try:
228 buf = str(photo_elt.elements(NS_VCARD, 'BINVAL').next())
212 except StopIteration: 229 except StopIteration:
213 type_ = None 230 log.warning(u"BINVAL element not found")
214 else: 231 raise Failure(exceptions.NotFound())
215 if type_ and type_ not in ("image/gif", "image/jpeg", "image/png"):
216 # TODO: handle other image formats (svg?)
217 log.warning(u"following avatar image format is not handled: {type} [{jid}]".format(
218 type=type_, jid=target.full()))
219 return
220 log.debug(u'Photo of type {type} found [{jid}]'.format(
221 type=type_, jid=target.full()))
222 try:
223 bin_elt = photo_elt.elements(NS_VCARD, 'BINVAL').next()
224 except StopIteration:
225 return
226 buf = str(bin_elt)
227 if not buf: 232 if not buf:
228 log.warning(u"empty avatar for {jid}".format(jid=target.full())) 233 log.warning(u"empty avatar for {jid}".format(jid=entity_jid.full()))
229 return 234 raise Failure(exceptions.NotFound())
230 log.debug(_(u'Decoding binary')) 235 log.debug(_(u'Decoding binary'))
231 decoded = b64decode(buf) 236 decoded = b64decode(buf)
237 del buf
232 image_hash = sha1(decoded).hexdigest() 238 image_hash = sha1(decoded).hexdigest()
233 self.saveAvatarFile(decoded, image_hash) 239 with client.cache.cacheData(
240 PLUGIN_INFO['import_name'],
241 image_hash,
242 mime_type,
243 # we keep in cache for 1 year
244 MAX_AGE
245 ) as f:
246 f.write(decoded)
234 return image_hash 247 return image_hash
235 248
236 @defer.inlineCallbacks 249 @defer.inlineCallbacks
237 def vCard2Dict(self, client, vcard, target): 250 def vCard2Dict(self, client, vcard, entity_jid):
238 """Convert a VCard to a dict, and save binaries""" 251 """Convert a VCard to a dict, and save binaries"""
239 log.debug(_("parsing vcard")) 252 log.debug((u"parsing vcard"))
240 dictionary = {} 253 vcard_dict = {}
241 254
242 for elem in vcard.elements(): 255 for elem in vcard.elements():
243 if elem.name == 'FN': 256 if elem.name == 'FN':
244 dictionary['fullname'] = unicode(elem) 257 vcard_dict['fullname'] = unicode(elem)
245 elif elem.name == 'NICKNAME': 258 elif elem.name == 'NICKNAME':
246 dictionary['nick'] = unicode(elem) 259 vcard_dict['nick'] = unicode(elem)
247 self.updateCache(client, target, 'nick', dictionary['nick']) 260 self.updateCache(client, entity_jid, 'nick', vcard_dict['nick'])
248 elif elem.name == 'URL': 261 elif elem.name == 'URL':
249 dictionary['website'] = unicode(elem) 262 vcard_dict['website'] = unicode(elem)
250 elif elem.name == 'EMAIL': 263 elif elem.name == 'EMAIL':
251 dictionary['email'] = unicode(elem) 264 vcard_dict['email'] = unicode(elem)
252 elif elem.name == 'BDAY': 265 elif elem.name == 'BDAY':
253 dictionary['birthday'] = unicode(elem) 266 vcard_dict['birthday'] = unicode(elem)
254 elif elem.name == 'PHOTO': 267 elif elem.name == 'PHOTO':
255 # TODO: handle EXTVAL 268 # TODO: handle EXTVAL
256 avatar_hash = yield threads.deferToThread(self.savePhoto, elem, target) 269 try:
257 if avatar_hash is None: # can happen e.g. in case of empty photo elem 270 avatar_hash = yield threads.deferToThread(
258 continue 271 self.savePhoto, client, elem, entity_jid)
259 dictionary['avatar'] = avatar_hash 272 except (exceptions.DataError, exceptions.NotFound) as e:
260 self.updateCache(client, target, 'avatar', dictionary['avatar']) 273 avatar_hash = ''
274 vcard_dict['avatar'] = avatar_hash
275 except Exception as e:
276 log.error(u"avatar saving error: {}".format(e))
277 avatar_hash = None
278 else:
279 vcard_dict['avatar'] = avatar_hash
280 self.updateCache(client, entity_jid, 'avatar', avatar_hash)
261 else: 281 else:
262 log.debug(_('FIXME: [%s] VCard tag is not managed yet') % elem.name) 282 log.debug(u'FIXME: [{}] VCard tag is not managed yet'.format(elem.name))
263 283
264 # if a data in cache doesn't exist anymore, we need to reset it 284 # if a data in cache doesn't exist anymore, we need to delete it
265 # so we check CACHED_DATA no gotten (i.e. not in dictionary keys) 285 # so we check CACHED_DATA no gotten (i.e. not in vcard_dict keys)
266 # and we reset them 286 # and we reset them
267 for datum in CACHED_DATA.difference(dictionary.keys()): 287 for datum in CACHED_DATA.difference(vcard_dict.keys()):
268 log.debug(u"reseting vcard datum [{datum}] for {entity}".format(datum=datum, entity=target.full())) 288 log.debug(u"reseting vcard datum [{datum}] for {entity}".format(datum=datum, entity=entity_jid.full()))
269 self.updateCache(client, target, datum, '') 289 self.updateCache(client, entity_jid, datum, None)
270 290
271 defer.returnValue(dictionary) 291 defer.returnValue(vcard_dict)
272 292
273 def _getCardCb(self, iq_elt, to_jid, client): 293 def _vCardCb(self, iq_elt, to_jid, client):
274 """Called after the first get IQ""" 294 """Called after the first get IQ"""
275 log.debug(_("VCard found")) 295 log.debug(_("VCard found"))
276 296
277 try: 297 try:
278 vcard_elt = iq_elt.elements(NS_VCARD, "vCard").next() 298 vcard_elt = iq_elt.elements(NS_VCARD, "vCard").next()
284 except KeyError: 304 except KeyError:
285 from_jid = client.jid.userhostJID() 305 from_jid = client.jid.userhostJID()
286 d = self.vCard2Dict(client, vcard_elt, from_jid) 306 d = self.vCard2Dict(client, vcard_elt, from_jid)
287 return d 307 return d
288 308
289 def _getCardEb(self, failure_, to_jid, client): 309 def _vCardEb(self, failure_, to_jid, client):
290 """Called when something is wrong with registration""" 310 """Called when something is wrong with registration"""
291 log.warning(u"Can't get vCard for {jid}: {failure}".format(jid=to_jid.full, failure=failure_)) 311 log.warning(u"Can't get vCard for {jid}: {failure}".format(jid=to_jid.full, failure=failure_))
292 self.updateCache(client, to_jid, "avatar", '') 312 self.updateCache(client, to_jid, "avatar", None)
293 313
294 def _getCard(self, target_s, profile_key=C.PROF_KEY_NONE): 314 def getCard(self, client, entity_jid):
295 client = self.host.getClient(profile_key)
296 return self.getCard(client, jid.JID(target_s))
297
298 def getCard(self, client, target):
299 """Ask server for VCard 315 """Ask server for VCard
300 316
301 @param target(jid.JID): jid from which we want the VCard 317 @param entity_jid(jid.JID): jid from which we want the VCard
302 @result: id to retrieve the profile 318 @result: id to retrieve the profile
303 """ 319 """
304 target_bare = target.userhostJID() 320 entity_jid = self.getBareOrFull(client, entity_jid)
305 to_jid = target if self.isRoom(client, target_bare) else target_bare 321 log.debug(u"Asking for {}'s VCard".format(entity_jid.full()))
306 log.debug(_(u"Asking for %s's VCard") % to_jid.userhost())
307 reg_request = client.IQ('get') 322 reg_request = client.IQ('get')
308 reg_request["from"] = client.jid.full() 323 reg_request["from"] = client.jid.full()
309 reg_request["to"] = to_jid.full() 324 reg_request["to"] = entity_jid.full()
310 reg_request.addElement('vCard', NS_VCARD) 325 reg_request.addElement('vCard', NS_VCARD)
311 d = reg_request.send(to_jid.full()).addCallbacks(self._getCardCb, self._getCardEb, callbackArgs=[to_jid, client], errbackArgs=[to_jid, client]) 326 d = reg_request.send(entity_jid.full()).addCallbacks(self._vCardCb, self._vCardEb, callbackArgs=[entity_jid, client], errbackArgs=[entity_jid, client])
312 return d 327 return d
313 328
314 def getAvatarFile(self, avatar_hash): 329 def _getCardCb(self, dummy, client, entity):
315 """Give the full path of avatar from hash 330 try:
316 @param avatar_hash(unicode): SHA1 hash 331 return client._cache_0054[entity.full()]['avatar']
317 @return(unicode): full_path or empty string if avatar_hash is empty 332 except KeyError:
318 or if avatar is not found in cache 333 raise Failure(exceptions.NotFound())
319 """ 334
320 if not avatar_hash: 335 def _getAvatar(self, entity, cache_only, hash_only, profile):
321 return ""
322 filename = self.avatar_path + '/' + avatar_hash
323 if not os.path.exists(filename):
324 log.error(_(u"Asking for an uncached avatar [%s]") % avatar_hash)
325 return ""
326 return filename
327
328 def _getAvatar(self, entity, profile):
329 client = self.host.getClient(profile) 336 client = self.host.getClient(profile)
330 return self.getAvatar(client, jid.JID(entity)) 337 d = self.getAvatar(client, jid.JID(entity))
331 338 d.addErrback(lambda dummy: '')
332 def _getAvatarGotCard(self, dummy, client, entity): 339
333 try: 340 return d
334 self.cache[client.profile][entity.full()]['avatar'] 341
342 def getAvatar(self, client, entity, cache_only=True, hash_only=False):
343 """get avatar full path or hash
344
345 if avatar is not in local cache, it will be requested to the server
346 @param entity(jid.JID): entity to get avatar from
347 @param cache_only(bool): if False, will request vCard if avatar is
348 not in cache
349 @param hash_only(bool): if True only return hash, not full path
350 @raise exceptions.NotFound: no avatar found
351 """
352 entity = self.getBareOrFull(client, entity)
353 full_path = None
354
355 try:
356 # we first check if we have avatar in cache
357 avatar_hash = client._cache_0054[entity.full()]['avatar']
358 if avatar_hash:
359 # avatar is known and exists
360 full_path = client.cache.getFilePath(avatar_hash)
361 if full_path is None:
362 # cache file is not available (probably expired)
363 raise KeyError
364 else:
365 # avatar has already been checked but it is not set
366 full_path = u''
335 except KeyError: 367 except KeyError:
336 return "" 368 # avatar is not in cache
337 369 if cache_only:
338 def getAvatar(self, client, entity): 370 return defer.fail(Failure(exceptions.NotFound()))
339 try: 371 # we request vCard to get avatar
340 avatar_hash = self.cache[client.profile][entity.full()]['avatar']
341 except KeyError:
342 d = self.getCard(client, entity) 372 d = self.getCard(client, entity)
343 d.addCallback(self._getAvatarGotCard, client, entity) 373 d.addCallback(self._getCardCb, client, entity)
344 else: 374 else:
375 # avatar is in cache, we can return hash
345 d = defer.succeed(avatar_hash) 376 d = defer.succeed(avatar_hash)
346 d.addCallback(self.getAvatarFile) 377
378 if not hash_only:
379 # full path is requested
380 if full_path is None:
381 d.addCallback(client.cache.getFilePath)
382 else:
383 d.addCallback(lambda dummy: full_path)
347 return d 384 return d
348 385
349 def _buildSetAvatar(self, vcard_set, filepath): 386 def _buildSetAvatar(self, client, vcard_set, file_path):
350 try: 387 # XXX: this method is executed in a separate thread
351 img = Image.open(filepath) 388 try:
389 img = Image.open(file_path)
352 except IOError: 390 except IOError:
353 return Failure(exceptions.DataError("Can't open image")) 391 return Failure(exceptions.DataError(u"Can't open image"))
354 392
355 if img.size != AVATAR_DIM: 393 if img.size != AVATAR_DIM:
356 img.thumbnail(AVATAR_DIM, Image.ANTIALIAS) 394 img.thumbnail(AVATAR_DIM, Image.ANTIALIAS)
357 if img.size[0] != img.size[1]: # we need to crop first 395 if img.size[0] != img.size[1]: # we need to crop first
358 left, upper = (0, 0) 396 left, upper = (0, 0)
370 408
371 vcard_elt = vcard_set.addElement('vCard', NS_VCARD) 409 vcard_elt = vcard_set.addElement('vCard', NS_VCARD)
372 photo_elt = vcard_elt.addElement('PHOTO') 410 photo_elt = vcard_elt.addElement('PHOTO')
373 photo_elt.addElement('TYPE', content='image/png') 411 photo_elt.addElement('TYPE', content='image/png')
374 photo_elt.addElement('BINVAL', content=b64encode(img_buf.getvalue())) 412 photo_elt.addElement('BINVAL', content=b64encode(img_buf.getvalue()))
375 img_hash = sha1(img_buf.getvalue()).hexdigest() 413 image_hash = sha1(img_buf.getvalue()).hexdigest()
376 self.saveAvatarFile(img_buf.getvalue(), img_hash) 414 with client.cache.cacheData(
377 return (vcard_set, img_hash) 415 PLUGIN_INFO['import_name'],
378 416 image_hash,
379 def setAvatar(self, filepath, profile_key=C.PROF_KEY_NONE): 417 "image/png",
418 MAX_AGE
419 ) as f:
420 f.write(img_buf.getvalue())
421 return vcard_set, image_hash
422
423 def _setAvatar(self, file_path, profile_key=C.PROF_KEY_NONE):
424 client = self.host.getClient(profile_key)
425 return self.setAvatar(client, file_path)
426
427 def setAvatar(self, client, file_path):
380 """Set avatar of the profile 428 """Set avatar of the profile
381 @param filepath: path of the image of the avatar""" 429
430 @param file_path: path of the image of the avatar
431 """
382 #TODO: This is a temporary way of setting the avatar, as other VCard information is not managed. 432 #TODO: This is a temporary way of setting the avatar, as other VCard information is not managed.
383 # A proper full VCard management should be done (and more generaly a public/private profile) 433 # A proper full VCard management should be done (and more generaly a public/private profile)
384 client = self.host.getClient(profile_key) 434 vcard_set = client.IQ()
385 435 d = threads.deferToThread(self._buildSetAvatar, client, vcard_set, file_path)
386 vcard_set = IQ(client.xmlstream, 'set')
387 d = threads.deferToThread(self._buildSetAvatar, vcard_set, filepath)
388 436
389 def elementBuilt(result): 437 def elementBuilt(result):
390 """Called once the image is at the right size/format, and the vcard set element is build""" 438 """Called once the image is at the right size/format, and the vcard set element is build"""
391 set_avatar_elt, img_hash = result 439 set_avatar_elt, image_hash = result
392 self.updateCache(client, client.jid.userhostJID(), 'avatar', img_hash) 440 self.updateCache(client, client.jid.userhostJID(), 'avatar', image_hash)
393 return set_avatar_elt.send().addCallback(lambda ignore: client.presence.available()) # FIXME: should send the current presence, not always "available" ! 441 return set_avatar_elt.send().addCallback(lambda ignore: client.presence.available()) # FIXME: should send the current presence, not always "available" !
394 442
395 d.addCallback(elementBuilt) 443 d.addCallback(elementBuilt)
396 444
397 return d 445 return d
411 return [disco.DiscoFeature(NS_VCARD)] 459 return [disco.DiscoFeature(NS_VCARD)]
412 460
413 def getDiscoItems(self, requestor, target, nodeIdentifier=''): 461 def getDiscoItems(self, requestor, target, nodeIdentifier=''):
414 return [] 462 return []
415 463
464 def _checkAvatarHash(self, dummy, client, entity, given_hash):
465 """check that hash in cash (i.e. computed hash) is the same as given one"""
466 # XXX: if they differ, the avater will be requested on each connection
467 # TODO: try to avoid re-requesting avatar in this case
468 computed_hash = self.plugin_parent.getCache(client, entity, 'avatar')
469 if computed_hash != given_hash:
470 log.warning(u"computed hash differs from given hash for {entity}:\n"
471 "computed: {computed}\ngiven: {given}".format(
472 entity=entity, computed=computed_hash, given=given_hash))
473
416 def update(self, presence): 474 def update(self, presence):
417 """Called on <presence/> stanza with vcard data 475 """Called on <presence/> stanza with vcard data
418 476
419 Check for avatar information, and get VCard if needed 477 Check for avatar information, and get VCard if needed
420 @param presend(domish.Element): <presence/> stanza 478 @param presend(domish.Element): <presence/> stanza
421 """ 479 """
422 from_jid = jid.JID(presence['from']) 480 client = self.parent
423 if from_jid.resource and not self.plugin_parent.isRoom(self.parent, from_jid): 481 entity_jid = self.plugin_parent.getBareOrFull(client, jid.JID(presence['from']))
424 from_jid = from_jid.userhostJID()
425 #FIXME: wokkel's data_form should be used here 482 #FIXME: wokkel's data_form should be used here
426 try: 483 try:
427 x_elt = presence.elements(NS_VCARD_UPDATE, 'x').next() 484 x_elt = presence.elements(NS_VCARD_UPDATE, 'x').next()
428 except StopIteration: 485 except StopIteration:
429 return 486 return
431 try: 488 try:
432 photo_elt = x_elt.elements(NS_VCARD_UPDATE, 'photo').next() 489 photo_elt = x_elt.elements(NS_VCARD_UPDATE, 'photo').next()
433 except StopIteration: 490 except StopIteration:
434 return 491 return
435 492
436 hash_ = str(photo_elt) 493 hash_ = unicode(photo_elt).strip()
494 if hash_ == C.HASH_SHA1_EMPTY:
495 hash_ = u''
496 old_avatar = self.plugin_parent.getCache(client, entity_jid, 'avatar')
497
498 if old_avatar == hash_:
499 # no change, we can return...
500 if hash_:
501 # ...but we double check that avatar is in cache
502 file_path = client.cache.getFilePath(hash_)
503 if file_path is None:
504 log.error(u"Avatar for [{}] should be in cache but it is not! We get it".format(entity_jid.full()))
505 self.plugin_parent.getCard(client, entity_jid)
506 else:
507 log.debug(u"avatar for {} already in cache".format(entity_jid.full()))
508 return
509
437 if not hash_: 510 if not hash_:
511 # the avatar has been removed
512 # XXX: we use empty string instead of None to indicate that we took avatar
513 # but it is empty on purpose
514 self.plugin_parent.updateCache(client, entity_jid, 'avatar', '')
438 return 515 return
439 old_avatar = self.plugin_parent.getCache(self.parent, from_jid, 'avatar') 516
440 filename = self.plugin_parent._getFilename(hash_) 517 file_path = client.cache.getFilePath(hash_)
441 if not old_avatar or old_avatar != hash_: 518 if file_path is not None:
442 if os.path.exists(filename): 519 log.debug(u"New avatar found for [{}], it's already in cache, we use it".format(entity_jid.full()))
443 log.debug(u"New avatar found for [{}], it's already in cache, we use it".format(from_jid.full())) 520 self.plugin_parent.updateCache(client, entity_jid, 'avatar', hash_)
444 self.plugin_parent.updateCache(self.parent, from_jid, 'avatar', hash_)
445 else:
446 log.debug(u'New avatar found for [{}], requesting vcard'.format(from_jid.full()))
447 self.plugin_parent.getCard(self.parent, from_jid)
448 else: 521 else:
449 if os.path.exists(filename): 522 log.debug(u'New avatar found for [{}], requesting vcard'.format(entity_jid.full()))
450 log.debug(u"avatar for {} already in cache".format(from_jid.full())) 523 d = self.plugin_parent.getCard(client, entity_jid)
451 else: 524 d.addCallback(self._checkAvatarHash, client, entity_jid, hash_)
452 log.error(u"Avatar for [{}] should be in cache but it is not ! We get it".format(from_jid.full()))
453 self.plugin_parent.getCard(self.parent, from_jid)
454