comparison sat/plugins/plugin_misc_identity.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 559a625a236b
children 704dada41df0
comparison
equal deleted inserted replaced
3253:1af840e84af7 3254:6cf4bd6972c2
1 #!/usr/bin/env python3 1 #!/usr/bin/env python3
2 2
3
4 # SAT plugin for managing xep-0054
5 # Copyright (C) 2009-2020 Jérôme Poisson (goffi@goffi.org) 3 # Copyright (C) 2009-2020 Jérôme Poisson (goffi@goffi.org)
6 # Copyright (C) 2014 Emmanuel Gil Peyrot (linkmauve@linkmauve.fr)
7 4
8 # This program is free software: you can redistribute it and/or modify 5 # This program is free software: you can redistribute it and/or modify
9 # it under the terms of the GNU Affero General Public License as published by 6 # it under the terms of the GNU Affero General Public License as published by
10 # the Free Software Foundation, either version 3 of the License, or 7 # the Free Software Foundation, either version 3 of the License, or
11 # (at your option) any later version. 8 # (at your option) any later version.
16 # GNU Affero General Public License for more details. 13 # GNU Affero General Public License for more details.
17 14
18 # You should have received a copy of the GNU Affero General Public License 15 # 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/>. 16 # along with this program. If not, see <http://www.gnu.org/licenses/>.
20 17
18 from collections import namedtuple
19 from pathlib import Path
20 from twisted.internet import defer
21 from twisted.words.protocols.jabber import jid
21 from sat.core.i18n import _ 22 from sat.core.i18n import _
22 from sat.core.constants import Const as C 23 from sat.core.constants import Const as C
23 from sat.core import exceptions 24 from sat.core import exceptions
24 from sat.core.log import getLogger 25 from sat.core.log import getLogger
26 from sat.memory import persistent
27 from sat.tools import image
28 from sat.tools import utils
29 from sat.tools.common import data_format
30
25 31
26 log = getLogger(__name__) 32 log = getLogger(__name__)
27 from twisted.internet import defer
28 from twisted.words.protocols.jabber import jid
29 import os.path
30 33
31 34
32 PLUGIN_INFO = { 35 PLUGIN_INFO = {
33 C.PI_NAME: "Identity Plugin", 36 C.PI_NAME: "Identity Plugin",
34 C.PI_IMPORT_NAME: "IDENTITY", 37 C.PI_IMPORT_NAME: "IDENTITY",
35 C.PI_TYPE: C.PLUG_TYPE_MISC, 38 C.PI_TYPE: C.PLUG_TYPE_MISC,
36 C.PI_PROTOCOLS: [], 39 C.PI_PROTOCOLS: [],
37 C.PI_DEPENDENCIES: ["XEP-0054"], 40 C.PI_DEPENDENCIES: [],
38 C.PI_RECOMMENDATIONS: [], 41 C.PI_RECOMMENDATIONS: ["XEP-0045"],
39 C.PI_MAIN: "Identity", 42 C.PI_MAIN: "Identity",
40 C.PI_HANDLER: "no", 43 C.PI_HANDLER: "no",
41 C.PI_DESCRIPTION: _("""Identity manager"""), 44 C.PI_DESCRIPTION: _("""Identity manager"""),
42 } 45 }
43 46
44 47 Callback = namedtuple("Callback", ("get", "set", "priority"))
45 class Identity(object): 48
49
50 class Identity:
51
46 def __init__(self, host): 52 def __init__(self, host):
47 log.info(_("Plugin Identity initialization")) 53 log.info(_("Plugin Identity initialization"))
48 self.host = host 54 self.host = host
49 self._v = host.plugins["XEP-0054"] 55 self._m = host.plugins.get("XEP-0045")
56 self.metadata = {
57 "avatar": {
58 "type": dict,
59 # convert avatar path to avatar metadata (and check validity)
60 "set_data_filter": self.avatarSetDataFilter,
61 # update profile avatar, so all frontends are aware
62 "set_post_treatment": self.avatarSetPostTreatment,
63 "update_is_new_data": self.avatarUpdateIsNewData,
64 "update_data_filter": self.avatarUpdateDataFilter,
65 # we store the metadata in database, to restore it on next connection
66 # (it is stored only for roster entities)
67 "store": True,
68 },
69 "nicknames": {
70 "type": list,
71 # accumulate all nicknames from all callbacks in a list instead
72 # of returning only the data from the first successful callback
73 "get_all": True,
74 # append nicknames from roster, resource, etc.
75 "get_post_treatment": self.nicknamesGetPostTreatment,
76 "update_is_new_data": self.nicknamesUpdateIsNewData,
77 "store": True,
78 },
79 }
80 host.trigger.add("roster_update", self._rosterUpdateTrigger)
81 host.memory.setSignalOnUpdate("avatar")
82 host.memory.setSignalOnUpdate("nicknames")
50 host.bridge.addMethod( 83 host.bridge.addMethod(
51 "identityGet", 84 "identityGet",
52 ".plugin", 85 ".plugin",
53 in_sign="ss", 86 in_sign="sasbs",
54 out_sign="a{ss}", 87 out_sign="s",
55 method=self._getIdentity, 88 method=self._getIdentity,
56 async_=True, 89 async_=True,
57 ) 90 )
58 host.bridge.addMethod( 91 host.bridge.addMethod(
59 "identitySet", 92 "identitySet",
60 ".plugin", 93 ".plugin",
61 in_sign="a{ss}s", 94 in_sign="ss",
62 out_sign="", 95 out_sign="",
63 method=self._setIdentity, 96 method=self._setIdentity,
64 async_=True, 97 async_=True,
65 ) 98 )
66 99 host.bridge.addMethod(
67 def _getIdentity(self, jid_str, profile): 100 "avatarGet",
68 jid_ = jid.JID(jid_str) 101 ".plugin",
69 client = self.host.getClient(profile) 102 in_sign="sbs",
70 return self.getIdentity(client, jid_) 103 out_sign="s",
71 104 method=self._getAvatar,
72 @defer.inlineCallbacks 105 async_=True,
73 def getIdentity(self, client, jid_): 106 )
74 """Retrieve identity of an entity 107 host.bridge.addMethod(
75 108 "avatarSet",
76 @param jid_(jid.JID): entity to check 109 ".plugin",
77 @return (dict(unicode, unicode)): identity data where key can be: 110 in_sign="sss",
78 - nick: nickname of the entity 111 out_sign="",
79 nickname is checked from, in this order: 112 method=self._setAvatar,
80 roster, vCard, user part of jid 113 async_=True,
81 cache is used when possible 114 )
82 """ 115
83 id_data = {} 116 async def profileConnecting(self, client):
84 # we first check roster 117 # we restore known identities from database
85 roster_item = yield client.roster.getItem(jid_.userhostJID()) 118 client._identity_storage = persistent.LazyPersistentBinaryDict(
86 if roster_item is not None and roster_item.name: 119 "identity", client.profile)
87 id_data["nick"] = roster_item.name 120
88 elif jid_.resource and self._v.isRoom(client, jid_): 121 stored_data = await client._identity_storage.all()
89 id_data["nick"] = jid_.resource 122
123 self.host.memory.storage.getPrivates(
124 namespace="identity", binary=True, profile=client.profile)
125
126 to_delete = []
127
128 for key, value in stored_data.items():
129 entity_s, name = key.split('\n')
130 if name not in self.metadata.keys():
131 log.debug(f"removing {key} from storage: not an allowed metadata name")
132 to_delete.append(key)
133 continue
134 entity = jid.JID(entity_s)
135
136 if name == 'avatar':
137 if value is not None:
138 try:
139 cache_uid = value['cache_uid']
140 if not cache_uid:
141 raise ValueError
142 except (ValueError, KeyError):
143 log.warning(
144 f"invalid data for {entity} avatar, it will be deleted: "
145 f"{value}")
146 to_delete.append(key)
147 continue
148 cache = self.host.common_cache.getMetadata(cache_uid)
149 if cache is None:
150 log.debug(
151 f"purging avatar for {entity}: it is not in cache anymore")
152 to_delete.append(key)
153 continue
154
155 self.host.memory.updateEntityData(
156 client, entity, name, value, silent=True
157 )
158
159 for key in to_delete:
160 await client._identity_storage.adel(key)
161
162 def _rosterUpdateTrigger(self, client, roster_item):
163 old_item = client.roster.getItem(roster_item.jid)
164 if old_item is None or old_item.name != roster_item.name:
165 log.debug(
166 f"roster nickname has been updated to {roster_item.name!r} for "
167 f"{roster_item.jid}"
168 )
169 defer.ensureDeferred(
170 self.update(
171 client,
172 "nicknames",
173 [roster_item.name],
174 roster_item.jid
175 )
176 )
177 return True
178
179 def register(self, metadata_name, cb_get, cb_set, priority=0):
180 """Register callbacks to handle identity metadata
181
182 @param metadata_name(str): name of metadata can be:
183 - avatar
184 - nicknames
185 @param cb_get(coroutine, Deferred): method to retrieve a metadata
186 the method will get client and metadata names to retrieve as arguments.
187 @param cb_set(coroutine, Deferred): method to set a metadata
188 the method will get client, metadata name to set, and value as argument.
189 @param priority(int): priority of this method for the given metadata.
190 methods with bigger priorities will be called first
191 """
192 if not metadata_name in self.metadata.keys():
193 raise ValueError(f"Invalid metadata_name: {metadata_name!r}")
194 callback = Callback(get=cb_get, set=cb_set, priority=priority)
195 cb_list = self.metadata[metadata_name].setdefault('callbacks', [])
196 cb_list.append(callback)
197 cb_list.sort(key=lambda c: c.priority, reverse=True)
198
199 def getIdentityJid(self, client, peer_jid):
200 """Return jid to use to set identity metadata
201
202 if it's a jid of a room occupant, full jid will be used
203 otherwise bare jid will be used
204 if None, bare jid of profile will be used
205 @return (jid.JID): jid to use for avatar
206 """
207 if peer_jid is None:
208 return client.jid.userhostJID()
209 if self._m is None:
210 return peer_jid.userhostJID()
90 else: 211 else:
91 #  and finally then vcard 212 return self._m.getBareOrFull(client, peer_jid)
92 nick = yield self._v.getNick(client, jid_) 213
93 if nick: 214 def checkType(self, metadata_name, value):
94 id_data["nick"] = nick 215 """Check that type used for a metadata is the one declared in self.metadata"""
95 elif jid_.user: 216 value_type = self.metadata[metadata_name]["type"]
96 id_data["nick"] = jid_.user.capitalize() 217 if not isinstance(value, value_type):
218 raise ValueError(
219 f"{value} has wrong type: it is {type(value)} while {value_type} was "
220 f"expected")
221
222 async def get(self, client, metadata_name, entity, use_cache=True):
223 """Retrieve identity metadata of an entity
224
225 if metadata is already in cache, it is returned. Otherwise, registered callbacks
226 will be tried in priority order (bigger to lower)
227 @param metadata_name(str): name of the metadata
228 must be one of self.metadata key
229 the name will also be used as entity data name in host.memory
230 @param entity(jid.JID, None): entity for which avatar is requested
231 None to use profile's jid
232 @param use_cache(bool): if False, cache won't be checked
233 """
234 entity = self.getIdentityJid(client, entity)
235 try:
236 metadata = self.metadata[metadata_name]
237 except KeyError:
238 raise ValueError(f"Invalid metadata name: {metadata_name!r}")
239 get_all = metadata.get('get_all', False)
240 if use_cache:
241 try:
242 data = self.host.memory.getEntityDatum(
243 client, entity, metadata_name)
244 except (KeyError, exceptions.UnknownEntityError):
245 pass
97 else: 246 else:
98 id_data["nick"] = jid_.userhost() 247 return data
99 248
100 try: 249 try:
101 avatar_path = id_data["avatar"] = yield self._v.getAvatar( 250 callbacks = metadata['callbacks']
102 client, jid_, cache_only=False 251 except KeyError:
103 ) 252 log.warning(_("No callback registered for {metadata_name}")
104 except exceptions.NotFound: 253 .format(metadata_name=metadata_name))
254 return [] if get_all else None
255
256 if get_all:
257 all_data = []
258
259 for callback in callbacks:
260 try:
261 data = await defer.ensureDeferred(callback.get(client, entity))
262 except exceptions.CancelError:
263 continue
264 except Exception as e:
265 log.warning(
266 _("Error while trying to get {metadata_name} with {callback}: {e}")
267 .format(callback=callback.get, metadata_name=metadata_name, e=e))
268 else:
269 if data:
270 self.checkType(metadata_name, data)
271 if get_all:
272 all_data.extend(data)
273 else:
274 break
275 else:
276 data = None
277
278 if get_all:
279 data = all_data
280
281 post_treatment = metadata.get("get_post_treatment")
282 if post_treatment is not None:
283 data = await utils.asDeferred(post_treatment, client, entity, data)
284
285 self.host.memory.updateEntityData(
286 client, entity, metadata_name, data)
287
288 if metadata.get('store', False):
289 key = f"{entity}\n{metadata_name}"
290 await client._identity_storage.aset(key, data)
291
292 return data
293
294 async def set(self, client, metadata_name, data, entity=None):
295 """Set identity metadata for an entity
296
297 Registered callbacks will be tried in priority order (bigger to lower)
298 @param metadata_name(str): name of the metadata
299 must be one of self.metadata key
300 the name will also be used to set entity data in host.memory
301 @param data(object): value to set
302 @param entity(jid.JID, None): entity for which avatar is requested
303 None to use profile's jid
304 """
305 entity = self.getIdentityJid(client, entity)
306 metadata = self.metadata[metadata_name]
307 data_filter = metadata.get("set_data_filter")
308 if data_filter is not None:
309 data = await utils.asDeferred(data_filter, client, entity, data)
310 self.checkType(metadata_name, data)
311
312 try:
313 callbacks = metadata['callbacks']
314 except KeyError:
315 log.warning(_("No callback registered for {metadata_name}")
316 .format(metadata_name=metadata_name))
317 return exceptions.FeatureNotFound(f"Can't set {metadata_name} for {entity}")
318
319 for callback in callbacks:
320 try:
321 await defer.ensureDeferred(callback.set(client, data, entity))
322 except exceptions.CancelError:
323 continue
324 except Exception as e:
325 log.warning(
326 _("Error while trying to set {metadata_name} with {callback}: {e}")
327 .format(callback=callback.set, metadata_name=metadata_name, e=e))
328 else:
329 break
330 else:
331 raise exceptions.FeatureNotFound(f"Can't set {metadata_name} for {entity}")
332
333 post_treatment = metadata.get("set_post_treatment")
334 if post_treatment is not None:
335 await utils.asDeferred(post_treatment, client, entity, data)
336
337 async def update(self, client, metadata_name, data, entity):
338 """Update a metadata in cache
339
340 This method may be called by plugins when an identity metadata is available.
341 """
342 entity = self.getIdentityJid(client, entity)
343 metadata = self.metadata[metadata_name]
344
345 try:
346 cached_data = self.host.memory.getEntityDatum(
347 client, entity, metadata_name)
348 except (KeyError, exceptions.UnknownEntityError):
349 # metadata is not cached, we do the update
105 pass 350 pass
106 else: 351 else:
107 if avatar_path: 352 # metadata is cached, we check if the new value differs from the cached one
108 id_data["avatar_basename"] = os.path.basename(avatar_path) 353 try:
354 update_is_new_data = metadata["update_is_new_data"]
355 except KeyError:
356 update_is_new_data = self.defaultUpdateIsNewData
357
358 if not update_is_new_data(client, entity, cached_data, data):
359 if cached_data is None:
360 log.debug(
361 f"{metadata_name} for {entity} is already disabled, nothing to "
362 f"do")
363 else:
364 log.debug(
365 f"{metadata_name} for {entity} is already in cache, nothing to "
366 f"do")
367 return
368
369 # we can't use the cache, so we do the update
370
371 log.debug(f"updating {metadata_name} for {entity}")
372
373 if metadata.get('get_all', False):
374 # get_all is set, meaning that we have to check all plugins
375 # so we first delete current cache
376 self.host.memory.delEntityDatum(client, entity, metadata_name)
377 # then fill it again by calling get, which will retrieve all values
378 await self.get(client, metadata_name, entity)
379 return
380
381 if data is not None:
382 data_filter = metadata['update_data_filter']
383 if data_filter is not None:
384 data = await utils.asDeferred(data_filter, client, entity, data)
385 self.checkType(metadata_name, data)
386
387 self.host.memory.updateEntityData(client, entity, metadata_name, data)
388
389 if metadata.get('store', False):
390 key = f"{entity}\n{metadata_name}"
391 await client._identity_storage.aset(key, data)
392
393 def defaultUpdateIsNewData(self, client, entity, cached_data, new_data):
394 return new_data != cached_data
395
396 def _getAvatar(self, entity, use_cache, profile):
397 client = self.host.getClient(profile)
398 entity = jid.JID(entity) if entity else None
399 d = defer.ensureDeferred(self.get(client, "avatar", entity, use_cache))
400 d.addCallback(lambda data: data_format.serialise(data))
401 return d
402
403 def _setAvatar(self, file_path, entity, profile_key=C.PROF_KEY_NONE):
404 client = self.host.getClient(profile_key)
405 entity = jid.JID(entity) if entity else None
406 return defer.ensureDeferred(
407 self.set(client, "avatar", file_path, entity))
408
409 async def avatarSetDataFilter(self, client, entity, file_path):
410 """Convert avatar file path to dict data"""
411 file_path = Path(file_path)
412 if not file_path.is_file():
413 raise ValueError(f"There is no file at {file_path} to use as avatar")
414 avatar_data = {
415 'path': file_path,
416 'media_type': image.guess_type(file_path),
417 }
418 media_type = avatar_data['media_type']
419 if media_type is None:
420 raise ValueError(f"Can't identify type of image at {file_path}")
421 if not media_type.startswith('image/'):
422 raise ValueError(f"File at {file_path} doesn't appear to be an image")
423 return avatar_data
424
425 async def avatarSetPostTreatment(self, client, entity, avatar_data):
426 """Update our own avatar"""
427 await self.update(client, "avatar", avatar_data, entity)
428
429 def avatarBuildMetadata(self, path, media_type=None, cache_uid=None):
430 """Helper method to generate avatar metadata
431
432 @param path(str, Path, None): path to avatar file
433 avatar file must be in cache
434 None if avatar is explicitely not set
435 @param media_type(str, None): type of the avatar file (MIME type)
436 @param cache_uid(str, None): UID of avatar in cache
437 @return (dict, None): avatar metadata
438 None if avatar is not set
439 """
440 if path is None:
441 return None
442 else:
443 if cache_uid is None:
444 raise ValueError("cache_uid must be set if path is set")
445 path = Path(path)
446 if media_type is None:
447 media_type = image.guess_type(path)
448
449 return {
450 "path": path,
451 "media_type": media_type,
452 "cache_uid": cache_uid,
453 }
454
455 def avatarUpdateIsNewData(self, client, entity, cached_data, file_path):
456 if cached_data is None:
457 return file_path is not None
458
459 if file_path is not None and file_path == cached_data['path']:
460 if file_path is None:
461 log.debug(
462 f"Avatar is already disabled for {entity}, nothing to do")
109 else: 463 else:
110 del id_data["avatar"] 464 log.debug(
111 465 f"Avatar at {file_path} is already used by {entity}, nothing "
112 defer.returnValue(id_data) 466 f"to do")
113 467 return
114 def _setIdentity(self, id_data, profile): 468
469 async def avatarUpdateDataFilter(self, client, entity, data):
470 if not isinstance(data, dict):
471 raise ValueError(f"Invalid data type ({type(data)}), a dict is expected")
472 mandatory_keys = {'path', 'cache_uid'}
473 if not data.keys() >= mandatory_keys:
474 raise ValueError(f"missing avatar data keys: {mandatory_keys - data.keys()}")
475 return data
476
477 async def nicknamesGetPostTreatment(self, client, entity, plugin_nicknames):
478 """Prepend nicknames from core locations + set default nickname
479
480 nicknames are checked from many locations, there is always at least
481 one nickname. First nickname of the list can be used in priority.
482 Nicknames are appended in this order:
483 - roster, plugins set nicknames
484 - if no nickname is found, user part of jid is then used, or bare jid
485 if there is no user part.
486 For MUC, room nick is always put first
487 """
488 # we first check roster
489 nicknames = []
490 if entity.resource:
491 # getIdentityJid let the resource only if the entity is a MUC room
492 # occupant jid
493 nicknames.append(entity.resource)
494
495 roster_item = client.roster.getItem(entity.userhostJID())
496 if roster_item is not None and roster_item.name:
497 # user set name has priority over entity set name
498 nicknames.append(roster_item.name)
499
500 nicknames.extend(plugin_nicknames)
501
502 if not nicknames:
503 if entity.user:
504 nicknames.append(entity.user.capitalize())
505 else:
506 nicknames.append(entity.userhost())
507
508 # we remove duplicates while preserving order with dict
509 return list(dict.fromkeys(nicknames))
510
511 def nicknamesUpdateIsNewData(self, client, entity, cached_data, new_nicknames):
512 return not set(new_nicknames).issubset(cached_data)
513
514 def _getIdentity(self, entity_s, metadata_filter, use_cache, profile):
515 entity = jid.JID(entity_s)
115 client = self.host.getClient(profile) 516 client = self.host.getClient(profile)
116 return self.setIdentity(client, id_data) 517 d = defer.ensureDeferred(
117 518 self.getIdentity(client, entity, metadata_filter, use_cache))
118 def setIdentity(self, client, id_data): 519 d.addCallback(lambda data: data_format.serialise(data))
520 return d
521
522 async def getIdentity(
523 self, client, entity=None, metadata_filter=None, use_cache=True):
524 """Retrieve identity of an entity
525
526 @param entity(jid.JID, None): entity to check
527 @param metadata_filter(list[str], None): if not None or empty, only return
528 metadata in this filter
529 @param use_cache(bool): if False, cache won't be checked
530 should be True most of time, to avoid useless network requests
531 @return (dict): identity data
532 """
533 id_data = {}
534
535 if not metadata_filter:
536 metadata_names = self.metadata.keys()
537 else:
538 metadata_names = metadata_filter
539
540 for metadata_name in metadata_names:
541 id_data[metadata_name] = await self.get(
542 client, metadata_name, entity, use_cache)
543
544 return id_data
545
546 def _setIdentity(self, id_data_s, profile):
547 client = self.host.getClient(profile)
548 id_data = data_format.deserialise(id_data_s)
549 return defer.ensureDeferred(self.setIdentity(client, id_data))
550
551 async def setIdentity(self, client, id_data):
119 """Update profile's identity 552 """Update profile's identity
120 553
121 @param id_data(dict[unicode, unicode]): data to update, key can be: 554 @param id_data(dict): data to update, key can be on of self.metadata keys
122 - nick: nickname 555 """
123 the vCard will be updated 556 if not id_data.keys() <= self.metadata.keys():
124 """ 557 raise ValueError(
125 if list(id_data.keys()) != ["nick"]: 558 f"Invalid metadata names: {id_data.keys() - self.metadata.keys()}")
126 raise NotImplementedError("Only nick can be updated for now") 559 for metadata_name, data in id_data.items():
127 if "nick" in id_data: 560 try:
128 return self._v.setNick(client, id_data["nick"]) 561 await self.set(client, metadata_name, data)
562 except Exception as e:
563 log.warning(
564 _("Can't set metadata {metadata_name!r}: {reason}")
565 .format(metadata_name=metadata_name, reason=e))