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`
This commit is contained in:
Goffi 2020-04-14 21:00:33 +02:00
commit 88072e0453
28 changed files with 1111 additions and 747 deletions

View file

@ -22,6 +22,9 @@ display the image can be specified in SàT configuration (cf. :ref:`configuratio
will try to open the image in a browser (which may sometimes result in using the default
image software of the platform).
When available, cached avatar is returned by defaut. If you want to ignore the cache, use
the ``--no-cache`` option (of course this can result in more network requests).
example
-------

View file

@ -9,8 +9,11 @@ get
===
Retrieve informations about the identity behind an XMPP entity. You only have to specify
the jid of the entity, and you'll get (if set) his/her/its nickname and a link to the
cached avatar.
the jid of the entity, and you'll get (if set) his/her/its nickname and data about the
avatar.
When available, cached values are returned by defaut. If you want to ignore the cache, use
the ``--no-cache`` option (of course this can result in more network requests).
example
--------
@ -22,13 +25,12 @@ Get identity information about an entity::
set
===
Set identity data to the server, using various XMPP extensions. You set the data to change
using ``-f KEY VALUE, --field KEY VALUE``, where ``KEY`` can only be ``nick`` at the
moment.
Set identity data to the server, using various XMPP extensions. So far, you can only
change the nickname of an entity using ``-n, --nick`` or or more times
example
-------
Set the nickname of default profile::
Set 2 nicknames for default profile::
$ jp identity set -f nick toto
$ jp identity set -n toto -n titi

View file

@ -272,6 +272,7 @@ doc_param_0=jids: list of entities bare jid, or empty list to have all jids in c
doc_param_1=keys: list of keys to get
doc_param_2=%(doc_profile)s
doc_return=dictionary with jids as keys and dictionary of asked key as values
values are serialised
if key doesn't exist for a jid, the resulting dictionary will not have it
[profileCreate]
@ -367,6 +368,19 @@ param_0_default="@DEFAULT@"
doc=Tell if a profile is connected
doc_param_0=%(doc_profile_key)s
[contactGet]
async=
type=method
category=core
sig_in=ss
sig_out=(a{ss}as)
param_1_default="@DEFAULT@"
doc=Return informations in roster about a contact
doc_param_1=%(doc_profile_key)s
doc_return=tuple with the following values:
- list of attributes as in [newContact]
- groups where the contact is
[getContacts]
async=
type=method
@ -711,7 +725,6 @@ doc_param_0=entity_jid: JID to add to roster
doc_param_1=%(doc_profile_key)s
[updateContact]
async=
type=method
category=core
sig_in=ssass

View file

@ -252,6 +252,12 @@ class DbusObject(dbus.service.Object):
def connect(self, profile_key="@DEFAULT@", password='', options={}, callback=None, errback=None):
return self._callback("connect", str(profile_key), str(password), options, callback=callback, errback=errback)
@dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX,
in_signature='ss', out_signature='(a{ss}as)',
async_callbacks=('callback', 'errback'))
def contactGet(self, arg_0, profile_key="@DEFAULT@", callback=None, errback=None):
return self._callback("contactGet", str(arg_0), str(profile_key), callback=callback, errback=errback)
@dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX,
in_signature='ss', out_signature='',
async_callbacks=('callback', 'errback'))
@ -596,9 +602,9 @@ class DbusObject(dbus.service.Object):
@dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX,
in_signature='ssass', out_signature='',
async_callbacks=('callback', 'errback'))
def updateContact(self, entity_jid, name, groups, profile_key="@DEFAULT@", callback=None, errback=None):
return self._callback("updateContact", str(entity_jid), str(name), groups, str(profile_key), callback=callback, errback=errback)
async_callbacks=None)
def updateContact(self, entity_jid, name, groups, profile_key="@DEFAULT@"):
return self._callback("updateContact", str(entity_jid), str(name), groups, str(profile_key))
def __attributes(self, in_sign):
"""Return arguments to user given a in_sign

View file

@ -379,7 +379,6 @@ class Const(object):
IGNORE = "ignore"
NO_LIMIT = -1 # used in bridge when a integer value is expected
DEFAULT_MAX_AGE = 1209600 # default max age of cached files, in seconds
HASH_SHA1_EMPTY = "da39a3ee5e6b4b0d3255bfef95601890afd80709"
STANZA_NAMES = ("iq", "message", "presence")
# Stream Hooks

View file

@ -102,12 +102,7 @@ class SAT(service.Service):
self.bridge.register_method("getFeatures", self.getFeatures)
self.bridge.register_method("profileNameGet", self.memory.getProfileName)
self.bridge.register_method("profilesListGet", self.memory.getProfilesList)
self.bridge.register_method(
"getEntityData",
lambda jid_, keys, profile: self.memory.getEntityData(
jid.JID(jid_), keys, profile
),
)
self.bridge.register_method("getEntityData", self.memory._getEntityData)
self.bridge.register_method("getEntitiesData", self.memory._getEntitiesData)
self.bridge.register_method("profileCreate", self.memory.createProfile)
self.bridge.register_method("asyncDeleteProfile", self.memory.asyncDeleteProfile)
@ -118,6 +113,7 @@ class SAT(service.Service):
self.bridge.register_method("profileSetDefault", self.memory.profileSetDefault)
self.bridge.register_method("connect", self._connect)
self.bridge.register_method("disconnect", self.disconnect)
self.bridge.register_method("contactGet", self._contactGet)
self.bridge.register_method("getContacts", self.getContacts)
self.bridge.register_method("getContactsFromGroup", self.getContactsFromGroup)
self.bridge.register_method("getMainResource", self.memory._getMainResource)
@ -539,6 +535,19 @@ class SAT(service.Service):
d_list.addCallback(buildFeatures, list(self.plugins.keys()))
return d_list
def _contactGet(self, entity_jid_s, profile_key):
client = self.getClient(profile_key)
entity_jid = jid.JID(entity_jid_s)
return defer.ensureDeferred(self.getContact(client, entity_jid))
async def getContact(self, client, entity_jid):
# we want to be sure that roster has been received
await client.roster.got_roster
item = client.roster.getItem(entity_jid)
if item is None:
raise exceptions.NotFound(f"{entity_jid} is not in roster!")
return (client.roster.getAttributes(item), list(item.groups))
def getContacts(self, profile_key):
client = self.getClient(profile_key)
@ -708,7 +717,7 @@ class SAT(service.Service):
for resource in resources:
res_jid = copy.copy(bare_jid)
res_jid.resource = resource
cache_data = self.memory.getEntityData(res_jid, profile_key=client.profile)
cache_data = self.memory.getEntityData(client, res_jid)
res_data = {
"resource": resource,
}
@ -957,17 +966,17 @@ class SAT(service.Service):
self.profiles[profile].presence.subscribe(to_jid)
def _updateContact(self, to_jid_s, name, groups, profile_key):
return self.updateContact(jid.JID(to_jid_s), name, groups, profile_key)
client = self.getClient(profile_key)
return self.updateContact(client, jid.JID(to_jid_s), name, groups)
def updateContact(self, to_jid, name, groups, profile_key):
def updateContact(self, client, to_jid, name, groups):
"""update a contact in roster list"""
profile = self.memory.getProfileName(profile_key)
assert profile
groups = set(groups)
roster_item = RosterItem(to_jid)
roster_item.name = name or None
roster_item.name = name or u''
roster_item.groups = set(groups)
return self.profiles[profile].roster.setItem(roster_item)
if not self.trigger.point("roster_update", client, roster_item):
return
return client.roster.setItem(roster_item)
def _delContact(self, to_jid_s, profile_key):
return self.delContact(jid.JID(to_jid_s), profile_key)

View file

@ -650,7 +650,7 @@ class SatXMPPEntity:
# we may have a groupchat message, we check if the we know this jid
try:
entity_type = self.host_app.memory.getEntityDatum(
data["to"], C.ENTITY_TYPE, self.profile
self, data["to"], C.ENTITY_TYPE
)
# FIXME: should entity_type manage resources ?
except (exceptions.UnknownEntityError, KeyError):
@ -1551,15 +1551,21 @@ class SatRosterProtocol(xmppim.RosterClientProtocol):
class SatPresenceProtocol(xmppim.PresenceClientProtocol):
def __init__(self, host):
xmppim.PresenceClientProtocol.__init__(self)
self.host = host
@property
def client(self):
return self.parent
def send(self, obj):
presence_d = defer.succeed(None)
if not self.host.trigger.point("Presence send", self.parent, obj, presence_d):
return
presence_d.addCallback(lambda __: super(SatPresenceProtocol, self).send(obj))
return presence_d
def availableReceived(self, entity, show=None, statuses=None, priority=0):
if not statuses:
@ -1604,7 +1610,7 @@ class SatPresenceProtocol(xmppim.PresenceClientProtocol):
# there is no need to send an unavailable signal
try:
presence = self.host.memory.getEntityDatum(
entity, "presence", self.parent.profile
self.client, entity, "presence"
)
except (KeyError, exceptions.UnknownEntityError):
# the entity has not been seen yet in this session
@ -1657,7 +1663,7 @@ class SatPresenceProtocol(xmppim.PresenceClientProtocol):
if not self.host.trigger.point("presence_available", presence_elt, self.parent):
return
self.send(presence_elt)
return self.send(presence_elt)
@defer.inlineCallbacks
def subscribed(self, entity):

View file

@ -177,7 +177,7 @@ class Discovery(object):
# we ignore cache, so we pretend we haven't found it
raise KeyError
cap_hash = self.host.memory.getEntityData(
jid_, [C.ENTITY_CAP_HASH], client.profile
client, jid_, [C.ENTITY_CAP_HASH]
)[C.ENTITY_CAP_HASH]
except (KeyError, exceptions.UnknownEntityError):
# capability hash is not available, we'll compute one
@ -191,7 +191,7 @@ class Discovery(object):
ext_form.typeCheck()
self.hashes[cap_hash] = disco_infos
self.host.memory.updateEntityData(
jid_, C.ENTITY_CAP_HASH, cap_hash, profile_key=client.profile
client, jid_, C.ENTITY_CAP_HASH, cap_hash
)
return disco_infos
@ -214,7 +214,7 @@ class Discovery(object):
# XXX we set empty disco in cache, to avoid getting an error or waiting
# for a timeout again the next time
self.host.memory.updateEntityData(
jid_, C.ENTITY_CAP_HASH, CAP_HASH_ERROR, profile_key=client.profile
client, jid_, C.ENTITY_CAP_HASH, CAP_HASH_ERROR
)
raise fail
@ -243,7 +243,7 @@ class Discovery(object):
# we cache items only for our own server and if node is not set
try:
items = self.host.memory.getEntityData(
jid_, ["DISCO_ITEMS"], client.profile
client, jid_, ["DISCO_ITEMS"]
)["DISCO_ITEMS"]
log.debug("[%s] disco items are in cache" % jid_.full())
if not use_cache:
@ -253,7 +253,7 @@ class Discovery(object):
log.debug("Caching [%s] disco items" % jid_.full())
items = yield client.disco.requestItems(jid_, nodeIdentifier=node)
self.host.memory.updateEntityData(
jid_, "DISCO_ITEMS", items, profile_key=client.profile
client, jid_, "DISCO_ITEMS", items
)
else:
try:

View file

@ -649,7 +649,7 @@ class Memory(object):
full_jid = copy.copy(entity_jid)
full_jid.resource = resource
try:
presence_data = self.getEntityDatum(full_jid, "presence", profile_key)
presence_data = self.getEntityDatum(client, full_jid, "presence")
except KeyError:
continue
entities_presence.setdefault(entity_jid, {})[
@ -667,14 +667,15 @@ class Memory(object):
@param statuses: dictionary of statuses
@param profile_key: %(doc_profile_key)s
"""
client = self.host.getClient(profile_key)
presence_data = PresenceTuple(show, priority, statuses)
self.updateEntityData(
entity_jid, "presence", presence_data, profile_key=profile_key
client, entity_jid, "presence", presence_data
)
if entity_jid.resource and show != C.PRESENCE_UNAVAILABLE:
# If a resource is available, bare jid should not have presence information
try:
self.delEntityDatum(entity_jid.userhostJID(), "presence", profile_key)
self.delEntityDatum(client, entity_jid.userhostJID(), "presence")
except (KeyError, exceptions.UnknownEntityError):
pass
@ -724,7 +725,7 @@ class Memory(object):
full_jid = copy.copy(entity_jid)
full_jid.resource = resource
try:
presence_data = self.getEntityDatum(full_jid, "presence", client.profile)
presence_data = self.getEntityDatum(client, full_jid, "presence")
except KeyError:
log.debug("Can't get presence data for {}".format(full_jid))
else:
@ -762,7 +763,7 @@ class Memory(object):
full_jid = copy.copy(entity_jid)
full_jid.resource = resource
try:
presence_data = self.getEntityDatum(full_jid, "presence", client.profile)
presence_data = self.getEntityDatum(client, full_jid, "presence")
except KeyError:
log.debug("No presence information for {}".format(full_jid))
continue
@ -812,7 +813,7 @@ class Memory(object):
yield full_jid
def updateEntityData(
self, entity_jid, key, value, silent=False, profile_key=C.PROF_KEY_NONE
self, client, entity_jid, key, value, silent=False
):
"""Set a misc data for an entity
@ -823,9 +824,7 @@ class Memory(object):
@param value: value for this key (eg: C.ENTITY_TYPE_MUC)
@param silent(bool): if True, doesn't send signal to frontend, even if there is a
signal flag (see setSignalOnUpdate)
@param profile_key: %(doc_profile_key)s
"""
client = self.host.getClient(profile_key)
profile_cache = self._getProfileCache(client)
if entity_jid in (C.ENTITY_ALL_RESOURCES, C.ENTITY_ALL):
entities = self.getAllEntitiesIter(client, entity_jid == C.ENTITY_ALL)
@ -839,29 +838,23 @@ class Memory(object):
entity_data[key] = value
if key in self._key_signals and not silent:
if not isinstance(value, str):
log.error(
"Setting a non string value ({}) for a key ({}) which has a signal flag".format(
value, key
)
)
else:
self.host.bridge.entityDataUpdated(
jid_.full(), key, value, self.getProfileName(profile_key)
)
self.host.bridge.entityDataUpdated(
jid_.full(),
key,
data_format.serialise(value),
client.profile
)
def delEntityDatum(self, entity_jid, key, profile_key):
def delEntityDatum(self, client, entity_jid, key):
"""Delete a data for an entity
@param entity_jid: JID of the entity, C.ENTITY_ALL_RESOURCES for all resources of all entities,
C.ENTITY_ALL for all entities (all resources + bare jids)
@param key: key to delete (eg: C.ENTITY_TYPE)
@param profile_key: %(doc_profile_key)s
@raise exceptions.UnknownEntityError: if entity is not in cache
@raise KeyError: key is not in cache
"""
client = self.host.getClient(profile_key)
profile_cache = self._getProfileCache(client)
if entity_jid in (C.ENTITY_ALL_RESOURCES, C.ENTITY_ALL):
entities = self.getAllEntitiesIter(client, entity_jid == C.ENTITY_ALL)
@ -884,12 +877,16 @@ class Memory(object):
raise e
def _getEntitiesData(self, entities_jids, keys_list, profile_key):
client = self.host.getClient(profile_key)
ret = self.getEntitiesData(
[jid.JID(jid_) for jid_ in entities_jids], keys_list, profile_key
client, [jid.JID(jid_) for jid_ in entities_jids], keys_list
)
return {jid_.full(): data for jid_, data in ret.items()}
return {
jid_.full(): {k: data_format.serialise(v) for k,v in data.items()}
for jid_, data in ret.items()
}
def getEntitiesData(self, entities_jids, keys_list=None, profile_key=C.PROF_KEY_NONE):
def getEntitiesData(self, client, entities_jids, keys_list=None):
"""Get a list of cached values for several entities at once
@param entities_jids: jids of the entities, or empty list for all entities in cache
@ -916,7 +913,6 @@ class Memory(object):
continue
return entity_data
client = self.host.getClient(profile_key)
profile_cache = self._getProfileCache(client)
ret_data = {}
if entities_jids:
@ -937,7 +933,11 @@ class Memory(object):
return ret_data
def getEntityData(self, entity_jid, keys_list=None, profile_key=C.PROF_KEY_NONE):
def _getEntityData(self, entity_jid_s, keys_list=None, profile=C.PROF_KEY_NONE):
return self.getEntityData(
self.host.getClient(profile), jid.JID(entity_jid_s), keys_list)
def getEntityData(self, client, entity_jid, keys_list=None):
"""Get a list of cached values for entity
@param entity_jid: JID of the entity
@ -949,7 +949,6 @@ class Memory(object):
@raise exceptions.UnknownEntityError: if entity is not in cache
"""
client = self.host.getClient(profile_key)
profile_cache = self._getProfileCache(client)
try:
entity_data = profile_cache[entity_jid.userhostJID()][entity_jid.resource]
@ -964,18 +963,17 @@ class Memory(object):
return {key: entity_data[key] for key in keys_list if key in entity_data}
def getEntityDatum(self, entity_jid, key, profile_key):
def getEntityDatum(self, client, entity_jid, key):
"""Get a datum from entity
@param entity_jid: JID of the entity
@param keys: key to get
@param profile_key: %(doc_profile_key)s
@param key: key to get
@return: requested value
@raise exceptions.UnknownEntityError: if entity is not in cache
@raise KeyError: if there is no value for this key and this entity
"""
return self.getEntityData(entity_jid, (key,), profile_key)[key]
return self.getEntityData(client, entity_jid, (key,))[key]
def delEntityCache(
self, entity_jid, delete_all_resources=True, profile_key=C.PROF_KEY_NONE
@ -1595,7 +1593,7 @@ class Memory(object):
self.getAvailableResources(client, entity_jid)
) # is any resource is available, entity is available
try:
presence_data = self.getEntityDatum(entity_jid, "presence", client.profile)
presence_data = self.getEntityDatum(client, entity_jid, "presence")
except KeyError:
log.debug("No presence information for {}".format(entity_jid))
return False

View file

@ -76,8 +76,6 @@ class Exp_Parrot(object):
def messageReceivedTrigger(self, client, message_elt, post_treat):
""" Check if source is linked and repeat message, else do nothing """
# TODO: many things are not repeated (subject, thread, etc)
profile = client.profile
client = self.host.getClient(profile)
from_jid = message_elt["from"]
try:
@ -95,7 +93,7 @@ class Exp_Parrot(object):
try:
entity_type = self.host.memory.getEntityData(
from_jid, [C.ENTITY_TYPE], profile)[C.ENTITY_TYPE]
client, from_jid, [C.ENTITY_TYPE])[C.ENTITY_TYPE]
except (UnknownEntityError, KeyError):
entity_type = "contact"
if entity_type == C.ENTITY_TYPE_MUC:

View file

@ -650,8 +650,8 @@ class PubsubSchema(object):
values["updated"] = now
if fill_author:
if not values.get("author"):
identity = yield self._i.getIdentity(client, client.jid)
values["author"] = identity["nick"]
id_data = yield self._i.getIdentity(client, None, ["nicknames"])
values["author"] = id_data['nicknames'][0]
if not values.get("author_jid"):
values["author_jid"] = client.jid.full()
item_id = yield self.sendDataFormItem(

View file

@ -1,9 +1,6 @@
#!/usr/bin/env python3
# SAT plugin for managing xep-0054
# Copyright (C) 2009-2020 Jérôme Poisson (goffi@goffi.org)
# Copyright (C) 2014 Emmanuel Gil Peyrot (linkmauve@linkmauve.fr)
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
@ -18,15 +15,21 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from collections import namedtuple
from pathlib import Path
from twisted.internet import defer
from twisted.words.protocols.jabber import jid
from sat.core.i18n import _
from sat.core.constants import Const as C
from sat.core import exceptions
from sat.core.log import getLogger
from sat.memory import persistent
from sat.tools import image
from sat.tools import utils
from sat.tools.common import data_format
log = getLogger(__name__)
from twisted.internet import defer
from twisted.words.protocols.jabber import jid
import os.path
PLUGIN_INFO = {
@ -34,95 +37,529 @@ PLUGIN_INFO = {
C.PI_IMPORT_NAME: "IDENTITY",
C.PI_TYPE: C.PLUG_TYPE_MISC,
C.PI_PROTOCOLS: [],
C.PI_DEPENDENCIES: ["XEP-0054"],
C.PI_RECOMMENDATIONS: [],
C.PI_DEPENDENCIES: [],
C.PI_RECOMMENDATIONS: ["XEP-0045"],
C.PI_MAIN: "Identity",
C.PI_HANDLER: "no",
C.PI_DESCRIPTION: _("""Identity manager"""),
}
Callback = namedtuple("Callback", ("get", "set", "priority"))
class Identity:
class Identity(object):
def __init__(self, host):
log.info(_("Plugin Identity initialization"))
self.host = host
self._v = host.plugins["XEP-0054"]
self._m = host.plugins.get("XEP-0045")
self.metadata = {
"avatar": {
"type": dict,
# convert avatar path to avatar metadata (and check validity)
"set_data_filter": self.avatarSetDataFilter,
# update profile avatar, so all frontends are aware
"set_post_treatment": self.avatarSetPostTreatment,
"update_is_new_data": self.avatarUpdateIsNewData,
"update_data_filter": self.avatarUpdateDataFilter,
# we store the metadata in database, to restore it on next connection
# (it is stored only for roster entities)
"store": True,
},
"nicknames": {
"type": list,
# accumulate all nicknames from all callbacks in a list instead
# of returning only the data from the first successful callback
"get_all": True,
# append nicknames from roster, resource, etc.
"get_post_treatment": self.nicknamesGetPostTreatment,
"update_is_new_data": self.nicknamesUpdateIsNewData,
"store": True,
},
}
host.trigger.add("roster_update", self._rosterUpdateTrigger)
host.memory.setSignalOnUpdate("avatar")
host.memory.setSignalOnUpdate("nicknames")
host.bridge.addMethod(
"identityGet",
".plugin",
in_sign="ss",
out_sign="a{ss}",
in_sign="sasbs",
out_sign="s",
method=self._getIdentity,
async_=True,
)
host.bridge.addMethod(
"identitySet",
".plugin",
in_sign="a{ss}s",
in_sign="ss",
out_sign="",
method=self._setIdentity,
async_=True,
)
host.bridge.addMethod(
"avatarGet",
".plugin",
in_sign="sbs",
out_sign="s",
method=self._getAvatar,
async_=True,
)
host.bridge.addMethod(
"avatarSet",
".plugin",
in_sign="sss",
out_sign="",
method=self._setAvatar,
async_=True,
)
def _getIdentity(self, jid_str, profile):
jid_ = jid.JID(jid_str)
client = self.host.getClient(profile)
return self.getIdentity(client, jid_)
async def profileConnecting(self, client):
# we restore known identities from database
client._identity_storage = persistent.LazyPersistentBinaryDict(
"identity", client.profile)
@defer.inlineCallbacks
def getIdentity(self, client, jid_):
"""Retrieve identity of an entity
stored_data = await client._identity_storage.all()
@param jid_(jid.JID): entity to check
@return (dict(unicode, unicode)): identity data where key can be:
- nick: nickname of the entity
nickname is checked from, in this order:
roster, vCard, user part of jid
cache is used when possible
self.host.memory.storage.getPrivates(
namespace="identity", binary=True, profile=client.profile)
to_delete = []
for key, value in stored_data.items():
entity_s, name = key.split('\n')
if name not in self.metadata.keys():
log.debug(f"removing {key} from storage: not an allowed metadata name")
to_delete.append(key)
continue
entity = jid.JID(entity_s)
if name == 'avatar':
if value is not None:
try:
cache_uid = value['cache_uid']
if not cache_uid:
raise ValueError
except (ValueError, KeyError):
log.warning(
f"invalid data for {entity} avatar, it will be deleted: "
f"{value}")
to_delete.append(key)
continue
cache = self.host.common_cache.getMetadata(cache_uid)
if cache is None:
log.debug(
f"purging avatar for {entity}: it is not in cache anymore")
to_delete.append(key)
continue
self.host.memory.updateEntityData(
client, entity, name, value, silent=True
)
for key in to_delete:
await client._identity_storage.adel(key)
def _rosterUpdateTrigger(self, client, roster_item):
old_item = client.roster.getItem(roster_item.jid)
if old_item is None or old_item.name != roster_item.name:
log.debug(
f"roster nickname has been updated to {roster_item.name!r} for "
f"{roster_item.jid}"
)
defer.ensureDeferred(
self.update(
client,
"nicknames",
[roster_item.name],
roster_item.jid
)
)
return True
def register(self, metadata_name, cb_get, cb_set, priority=0):
"""Register callbacks to handle identity metadata
@param metadata_name(str): name of metadata can be:
- avatar
- nicknames
@param cb_get(coroutine, Deferred): method to retrieve a metadata
the method will get client and metadata names to retrieve as arguments.
@param cb_set(coroutine, Deferred): method to set a metadata
the method will get client, metadata name to set, and value as argument.
@param priority(int): priority of this method for the given metadata.
methods with bigger priorities will be called first
"""
id_data = {}
# we first check roster
roster_item = yield client.roster.getItem(jid_.userhostJID())
if roster_item is not None and roster_item.name:
id_data["nick"] = roster_item.name
elif jid_.resource and self._v.isRoom(client, jid_):
id_data["nick"] = jid_.resource
if not metadata_name in self.metadata.keys():
raise ValueError(f"Invalid metadata_name: {metadata_name!r}")
callback = Callback(get=cb_get, set=cb_set, priority=priority)
cb_list = self.metadata[metadata_name].setdefault('callbacks', [])
cb_list.append(callback)
cb_list.sort(key=lambda c: c.priority, reverse=True)
def getIdentityJid(self, client, peer_jid):
"""Return jid to use to set identity metadata
if it's a jid of a room occupant, full jid will be used
otherwise bare jid will be used
if None, bare jid of profile will be used
@return (jid.JID): jid to use for avatar
"""
if peer_jid is None:
return client.jid.userhostJID()
if self._m is None:
return peer_jid.userhostJID()
else:
#  and finally then vcard
nick = yield self._v.getNick(client, jid_)
if nick:
id_data["nick"] = nick
elif jid_.user:
id_data["nick"] = jid_.user.capitalize()
return self._m.getBareOrFull(client, peer_jid)
def checkType(self, metadata_name, value):
"""Check that type used for a metadata is the one declared in self.metadata"""
value_type = self.metadata[metadata_name]["type"]
if not isinstance(value, value_type):
raise ValueError(
f"{value} has wrong type: it is {type(value)} while {value_type} was "
f"expected")
async def get(self, client, metadata_name, entity, use_cache=True):
"""Retrieve identity metadata of an entity
if metadata is already in cache, it is returned. Otherwise, registered callbacks
will be tried in priority order (bigger to lower)
@param metadata_name(str): name of the metadata
must be one of self.metadata key
the name will also be used as entity data name in host.memory
@param entity(jid.JID, None): entity for which avatar is requested
None to use profile's jid
@param use_cache(bool): if False, cache won't be checked
"""
entity = self.getIdentityJid(client, entity)
try:
metadata = self.metadata[metadata_name]
except KeyError:
raise ValueError(f"Invalid metadata name: {metadata_name!r}")
get_all = metadata.get('get_all', False)
if use_cache:
try:
data = self.host.memory.getEntityDatum(
client, entity, metadata_name)
except (KeyError, exceptions.UnknownEntityError):
pass
else:
id_data["nick"] = jid_.userhost()
return data
try:
avatar_path = id_data["avatar"] = yield self._v.getAvatar(
client, jid_, cache_only=False
)
except exceptions.NotFound:
callbacks = metadata['callbacks']
except KeyError:
log.warning(_("No callback registered for {metadata_name}")
.format(metadata_name=metadata_name))
return [] if get_all else None
if get_all:
all_data = []
for callback in callbacks:
try:
data = await defer.ensureDeferred(callback.get(client, entity))
except exceptions.CancelError:
continue
except Exception as e:
log.warning(
_("Error while trying to get {metadata_name} with {callback}: {e}")
.format(callback=callback.get, metadata_name=metadata_name, e=e))
else:
if data:
self.checkType(metadata_name, data)
if get_all:
all_data.extend(data)
else:
break
else:
data = None
if get_all:
data = all_data
post_treatment = metadata.get("get_post_treatment")
if post_treatment is not None:
data = await utils.asDeferred(post_treatment, client, entity, data)
self.host.memory.updateEntityData(
client, entity, metadata_name, data)
if metadata.get('store', False):
key = f"{entity}\n{metadata_name}"
await client._identity_storage.aset(key, data)
return data
async def set(self, client, metadata_name, data, entity=None):
"""Set identity metadata for an entity
Registered callbacks will be tried in priority order (bigger to lower)
@param metadata_name(str): name of the metadata
must be one of self.metadata key
the name will also be used to set entity data in host.memory
@param data(object): value to set
@param entity(jid.JID, None): entity for which avatar is requested
None to use profile's jid
"""
entity = self.getIdentityJid(client, entity)
metadata = self.metadata[metadata_name]
data_filter = metadata.get("set_data_filter")
if data_filter is not None:
data = await utils.asDeferred(data_filter, client, entity, data)
self.checkType(metadata_name, data)
try:
callbacks = metadata['callbacks']
except KeyError:
log.warning(_("No callback registered for {metadata_name}")
.format(metadata_name=metadata_name))
return exceptions.FeatureNotFound(f"Can't set {metadata_name} for {entity}")
for callback in callbacks:
try:
await defer.ensureDeferred(callback.set(client, data, entity))
except exceptions.CancelError:
continue
except Exception as e:
log.warning(
_("Error while trying to set {metadata_name} with {callback}: {e}")
.format(callback=callback.set, metadata_name=metadata_name, e=e))
else:
break
else:
raise exceptions.FeatureNotFound(f"Can't set {metadata_name} for {entity}")
post_treatment = metadata.get("set_post_treatment")
if post_treatment is not None:
await utils.asDeferred(post_treatment, client, entity, data)
async def update(self, client, metadata_name, data, entity):
"""Update a metadata in cache
This method may be called by plugins when an identity metadata is available.
"""
entity = self.getIdentityJid(client, entity)
metadata = self.metadata[metadata_name]
try:
cached_data = self.host.memory.getEntityDatum(
client, entity, metadata_name)
except (KeyError, exceptions.UnknownEntityError):
# metadata is not cached, we do the update
pass
else:
if avatar_path:
id_data["avatar_basename"] = os.path.basename(avatar_path)
else:
del id_data["avatar"]
# metadata is cached, we check if the new value differs from the cached one
try:
update_is_new_data = metadata["update_is_new_data"]
except KeyError:
update_is_new_data = self.defaultUpdateIsNewData
defer.returnValue(id_data)
if not update_is_new_data(client, entity, cached_data, data):
if cached_data is None:
log.debug(
f"{metadata_name} for {entity} is already disabled, nothing to "
f"do")
else:
log.debug(
f"{metadata_name} for {entity} is already in cache, nothing to "
f"do")
return
def _setIdentity(self, id_data, profile):
# we can't use the cache, so we do the update
log.debug(f"updating {metadata_name} for {entity}")
if metadata.get('get_all', False):
# get_all is set, meaning that we have to check all plugins
# so we first delete current cache
self.host.memory.delEntityDatum(client, entity, metadata_name)
# then fill it again by calling get, which will retrieve all values
await self.get(client, metadata_name, entity)
return
if data is not None:
data_filter = metadata['update_data_filter']
if data_filter is not None:
data = await utils.asDeferred(data_filter, client, entity, data)
self.checkType(metadata_name, data)
self.host.memory.updateEntityData(client, entity, metadata_name, data)
if metadata.get('store', False):
key = f"{entity}\n{metadata_name}"
await client._identity_storage.aset(key, data)
def defaultUpdateIsNewData(self, client, entity, cached_data, new_data):
return new_data != cached_data
def _getAvatar(self, entity, use_cache, profile):
client = self.host.getClient(profile)
return self.setIdentity(client, id_data)
entity = jid.JID(entity) if entity else None
d = defer.ensureDeferred(self.get(client, "avatar", entity, use_cache))
d.addCallback(lambda data: data_format.serialise(data))
return d
def setIdentity(self, client, id_data):
def _setAvatar(self, file_path, entity, profile_key=C.PROF_KEY_NONE):
client = self.host.getClient(profile_key)
entity = jid.JID(entity) if entity else None
return defer.ensureDeferred(
self.set(client, "avatar", file_path, entity))
async def avatarSetDataFilter(self, client, entity, file_path):
"""Convert avatar file path to dict data"""
file_path = Path(file_path)
if not file_path.is_file():
raise ValueError(f"There is no file at {file_path} to use as avatar")
avatar_data = {
'path': file_path,
'media_type': image.guess_type(file_path),
}
media_type = avatar_data['media_type']
if media_type is None:
raise ValueError(f"Can't identify type of image at {file_path}")
if not media_type.startswith('image/'):
raise ValueError(f"File at {file_path} doesn't appear to be an image")
return avatar_data
async def avatarSetPostTreatment(self, client, entity, avatar_data):
"""Update our own avatar"""
await self.update(client, "avatar", avatar_data, entity)
def avatarBuildMetadata(self, path, media_type=None, cache_uid=None):
"""Helper method to generate avatar metadata
@param path(str, Path, None): path to avatar file
avatar file must be in cache
None if avatar is explicitely not set
@param media_type(str, None): type of the avatar file (MIME type)
@param cache_uid(str, None): UID of avatar in cache
@return (dict, None): avatar metadata
None if avatar is not set
"""
if path is None:
return None
else:
if cache_uid is None:
raise ValueError("cache_uid must be set if path is set")
path = Path(path)
if media_type is None:
media_type = image.guess_type(path)
return {
"path": path,
"media_type": media_type,
"cache_uid": cache_uid,
}
def avatarUpdateIsNewData(self, client, entity, cached_data, file_path):
if cached_data is None:
return file_path is not None
if file_path is not None and file_path == cached_data['path']:
if file_path is None:
log.debug(
f"Avatar is already disabled for {entity}, nothing to do")
else:
log.debug(
f"Avatar at {file_path} is already used by {entity}, nothing "
f"to do")
return
async def avatarUpdateDataFilter(self, client, entity, data):
if not isinstance(data, dict):
raise ValueError(f"Invalid data type ({type(data)}), a dict is expected")
mandatory_keys = {'path', 'cache_uid'}
if not data.keys() >= mandatory_keys:
raise ValueError(f"missing avatar data keys: {mandatory_keys - data.keys()}")
return data
async def nicknamesGetPostTreatment(self, client, entity, plugin_nicknames):
"""Prepend nicknames from core locations + set default nickname
nicknames are checked from many locations, there is always at least
one nickname. First nickname of the list can be used in priority.
Nicknames are appended in this order:
- roster, plugins set nicknames
- if no nickname is found, user part of jid is then used, or bare jid
if there is no user part.
For MUC, room nick is always put first
"""
# we first check roster
nicknames = []
if entity.resource:
# getIdentityJid let the resource only if the entity is a MUC room
# occupant jid
nicknames.append(entity.resource)
roster_item = client.roster.getItem(entity.userhostJID())
if roster_item is not None and roster_item.name:
# user set name has priority over entity set name
nicknames.append(roster_item.name)
nicknames.extend(plugin_nicknames)
if not nicknames:
if entity.user:
nicknames.append(entity.user.capitalize())
else:
nicknames.append(entity.userhost())
# we remove duplicates while preserving order with dict
return list(dict.fromkeys(nicknames))
def nicknamesUpdateIsNewData(self, client, entity, cached_data, new_nicknames):
return not set(new_nicknames).issubset(cached_data)
def _getIdentity(self, entity_s, metadata_filter, use_cache, profile):
entity = jid.JID(entity_s)
client = self.host.getClient(profile)
d = defer.ensureDeferred(
self.getIdentity(client, entity, metadata_filter, use_cache))
d.addCallback(lambda data: data_format.serialise(data))
return d
async def getIdentity(
self, client, entity=None, metadata_filter=None, use_cache=True):
"""Retrieve identity of an entity
@param entity(jid.JID, None): entity to check
@param metadata_filter(list[str], None): if not None or empty, only return
metadata in this filter
@param use_cache(bool): if False, cache won't be checked
should be True most of time, to avoid useless network requests
@return (dict): identity data
"""
id_data = {}
if not metadata_filter:
metadata_names = self.metadata.keys()
else:
metadata_names = metadata_filter
for metadata_name in metadata_names:
id_data[metadata_name] = await self.get(
client, metadata_name, entity, use_cache)
return id_data
def _setIdentity(self, id_data_s, profile):
client = self.host.getClient(profile)
id_data = data_format.deserialise(id_data_s)
return defer.ensureDeferred(self.setIdentity(client, id_data))
async def setIdentity(self, client, id_data):
"""Update profile's identity
@param id_data(dict[unicode, unicode]): data to update, key can be:
- nick: nickname
the vCard will be updated
@param id_data(dict): data to update, key can be on of self.metadata keys
"""
if list(id_data.keys()) != ["nick"]:
raise NotImplementedError("Only nick can be updated for now")
if "nick" in id_data:
return self._v.setNick(client, id_data["nick"])
if not id_data.keys() <= self.metadata.keys():
raise ValueError(
f"Invalid metadata names: {id_data.keys() - self.metadata.keys()}")
for metadata_name, data in id_data.items():
try:
await self.set(client, metadata_name, data)
except Exception as e:
log.warning(
_("Can't set metadata {metadata_name!r}: {reason}")
.format(metadata_name=metadata_name, reason=e))

View file

@ -71,7 +71,7 @@ class Watched(object):
# we check that the previous presence was unavailable (no notification else)
try:
old_show = self.host.memory.getEntityDatum(
entity, "presence", client.profile).show
client, entity, "presence").show
except (KeyError, exceptions.UnknownEntityError):
old_show = C.PRESENCE_UNAVAILABLE

View file

@ -235,6 +235,31 @@ class XEP_0045(object):
else:
return True
def isRoom(self, client, entity_jid):
"""Tell if a jid is a joined MUC
similar to isJoinedRoom but returns a boolean
@param entity_jid(jid.JID): full or bare jid of the entity check
@return (bool): True if the bare jid of the entity is a room jid
"""
try:
self.checkRoomJoined(client, entity_jid.userhostJID())
except exceptions.NotFound:
return False
else:
return True
def getBareOrFull(self, client, peer_jid):
"""use full jid if peer_jid is an occupant of a room, bare jid else
@param peer_jid(jid.JID): entity to test
@return (jid.JID): bare or full jid
"""
if peer_jid.resource:
if not self.isRoom(client, peer_jid):
return peer_jid.userhostJID()
return peer_jid
def _getRoomJoinedArgs(self, room, profile):
return [
room.roomJID.userhost(),
@ -1148,8 +1173,8 @@ class SatMUCClient(muc.MUCClient):
# we set type so we don't have to use a deferred
# with disco to check entity type
self.host.memory.updateEntityData(
room.roomJID, C.ENTITY_TYPE, C.ENTITY_TYPE_MUC,
profile_key=self.client.profile)
self.client, room.roomJID, C.ENTITY_TYPE, C.ENTITY_TYPE_MUC
)
elif room.state not in (ROOM_STATE_OCCUPANTS, ROOM_STATE_LIVE):
log.warning(
"Received user presence data in a room before its initialisation "

View file

@ -1,6 +1,5 @@
#!/usr/bin/env python3
# SAT plugin for managing xep-0054
# Copyright (C) 2009-2020 Jérôme Poisson (goffi@goffi.org)
# Copyright (C) 2014 Emmanuel Gil Peyrot (linkmauve@linkmauve.fr)
@ -18,9 +17,10 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import mimetypes
import io
from base64 import b64decode, b64encode
from hashlib import sha1
from pathlib import Path
from zope.interface import implementer
from twisted.internet import threads, defer
from twisted.words.protocols.jabber import jid, error
@ -32,6 +32,7 @@ from sat.core.i18n import _
from sat.core.constants import Const as C
from sat.core.log import getLogger
from sat.memory import persistent
from sat.tools import image
log = getLogger(__name__)
@ -41,15 +42,26 @@ except:
raise exceptions.MissingModule(
"Missing module pillow, please download/install it from https://python-pillow.github.io"
)
import io
try:
from twisted.words.protocols.xmlstream import XMPPHandler
except ImportError:
from wokkel.subprotocols import XMPPHandler
PLUGIN_INFO = {
C.PI_NAME: "XEP 0054 Plugin",
C.PI_IMPORT_NAME: "XEP-0054",
C.PI_TYPE: "XEP",
C.PI_PROTOCOLS: ["XEP-0054", "XEP-0153"],
C.PI_DEPENDENCIES: ["IDENTITY"],
C.PI_RECOMMENDATIONS: [],
C.PI_MAIN: "XEP_0054",
C.PI_HANDLER: "yes",
C.PI_DESCRIPTION: _("""Implementation of vcard-temp"""),
}
AVATAR_PATH = "avatars"
# AVATAR_DIM = (64, 64) #  FIXME: dim are not adapted to modern resolutions !
AVATAR_DIM = (128, 128)
IQ_GET = '/iq[@type="get"]'
@ -60,158 +72,39 @@ PRESENCE = "/presence"
NS_VCARD_UPDATE = "vcard-temp:x:update"
VCARD_UPDATE = PRESENCE + '/x[@xmlns="' + NS_VCARD_UPDATE + '"]'
CACHED_DATA = {"avatar", "nick"}
MAX_AGE = 60 * 60 * 24 * 365
PLUGIN_INFO = {
C.PI_NAME: "XEP 0054 Plugin",
C.PI_IMPORT_NAME: "XEP-0054",
C.PI_TYPE: "XEP",
C.PI_PROTOCOLS: ["XEP-0054", "XEP-0153"],
C.PI_DEPENDENCIES: [],
C.PI_RECOMMENDATIONS: ["XEP-0045"],
C.PI_MAIN: "XEP_0054",
C.PI_HANDLER: "yes",
C.PI_DESCRIPTION: _("""Implementation of vcard-temp"""),
}
HASH_SHA1_EMPTY = "da39a3ee5e6b4b0d3255bfef95601890afd80709"
class XEP_0054(object):
# TODO: - check that nickname is ok
# - refactor the code/better use of Wokkel
# - get missing values
def __init__(self, host):
log.info(_("Plugin XEP_0054 initialization"))
self.host = host
host.bridge.addMethod(
"avatarGet",
".plugin",
in_sign="sbbs",
out_sign="s",
method=self._getAvatar,
async_=True,
)
host.bridge.addMethod(
"avatarSet",
".plugin",
in_sign="ss",
out_sign="",
method=self._setAvatar,
async_=True,
)
self._i = host.plugins['IDENTITY']
self._i.register('avatar', self.getAvatar, self.setAvatar)
self._i.register('nicknames', self.getNicknames, self.setNicknames)
host.trigger.add("presence_available", self.presenceAvailableTrigger)
host.memory.setSignalOnUpdate("avatar")
host.memory.setSignalOnUpdate("nick")
def getHandler(self, client):
return XEP_0054_handler(self)
def isRoom(self, client, entity_jid):
"""Tell if a jid is a MUC one
@param entity_jid(jid.JID): full or bare jid of the entity check
@return (bool): True if the bare jid of the entity is a room jid
"""
try:
muc_plg = self.host.plugins["XEP-0045"]
except KeyError:
return False
try:
muc_plg.checkRoomJoined(client, entity_jid.userhostJID())
except exceptions.NotFound:
return False
else:
return True
def getBareOrFull(self, client, jid_):
"""use full jid if jid_ is an occupant of a room, bare jid else
@param jid_(jid.JID): entity to test
@return (jid.JID): bare or full jid
"""
if jid_.resource:
if not self.isRoom(client, jid_):
return jid_.userhostJID()
return jid_
def presenceAvailableTrigger(self, presence_elt, client):
if client.jid.userhost() in client._cache_0054:
try:
avatar_hash = client._cache_0054[client.jid.userhost()]["avatar"]
except KeyError:
log.info("No avatar in cache for {}".format(client.jid.userhost()))
return True
x_elt = domish.Element((NS_VCARD_UPDATE, "x"))
x_elt.addElement("photo", content=avatar_hash)
presence_elt.addChild(x_elt)
try:
avatar_hash = client._xep_0054_avatar_hashes[client.jid.userhost()]
except KeyError:
log.info(
_("No avatar in cache for {profile}")
.format(profile=client.profile))
return True
x_elt = domish.Element((NS_VCARD_UPDATE, "x"))
x_elt.addElement("photo", content=avatar_hash)
presence_elt.addChild(x_elt)
return True
@defer.inlineCallbacks
def profileConnecting(self, client):
client._cache_0054 = persistent.PersistentBinaryDict(NS_VCARD, client.profile)
yield client._cache_0054.load()
self._fillCachedValues(client.profile)
def _fillCachedValues(self, profile):
# FIXME: this may need to be reworked
# the current naive approach keeps a map between all jids
# in persistent cache, then put avatar hashs in memory.
# Hashes should be shared between profiles (or not ? what
# if the avatar is different depending on who is requesting it
# this is not possible with vcard-tmp, but it is with XEP-0084).
# Loading avatar on demand per jid may be an option to investigate.
client = self.host.getClient(profile)
for jid_s, data in client._cache_0054.items():
jid_ = jid.JID(jid_s)
for name in CACHED_DATA:
try:
value = data[name]
if value is None:
log.error(
"{name} value for {jid_} is None, ignoring".format(
name=name, jid_=jid_
)
)
continue
self.host.memory.updateEntityData(
jid_, name, data[name], silent=True, profile_key=profile
)
except KeyError:
pass
def updateCache(self, client, jid_, name, value):
"""update cache value
save value in memory in case of change
@param jid_(jid.JID): jid of the owner of the vcard
@param name(str): name of the item which changed
@param value(unicode, None): new value of the item
None to delete
"""
jid_ = self.getBareOrFull(client, jid_)
jid_s = jid_.full()
if value is None:
try:
self.host.memory.delEntityDatum(jid_, name, client.profile)
except (KeyError, exceptions.UnknownEntityError):
pass
if name in CACHED_DATA:
try:
del client._cache_0054[jid_s][name]
except KeyError:
pass
else:
client._cache_0054.force(jid_s)
else:
self.host.memory.updateEntityData(
jid_, name, value, profile_key=client.profile
)
if name in CACHED_DATA:
client._cache_0054.setdefault(jid_s, {})[name] = value
client._cache_0054.force(jid_s)
async def profileConnecting(self, client):
client._xep_0054_avatar_hashes = persistent.PersistentDict(
NS_VCARD, client.profile)
await client._xep_0054_avatar_hashes.load()
def getCache(self, client, entity_jid, name):
"""return cached value for jid
@ -219,9 +112,9 @@ class XEP_0054(object):
@param entity_jid(jid.JID): target contact
@param name(unicode): name of the value ('nick' or 'avatar')
@return(unicode, None): wanted value or None"""
entity_jid = self.getBareOrFull(client, entity_jid)
entity_jid = self._i.getIdentityJid(client, entity_jid)
try:
data = self.host.memory.getEntityData(entity_jid, [name], client.profile)
data = self.host.memory.getEntityData(client, entity_jid, [name])
except exceptions.UnknownEntityError:
return None
return data.get(name)
@ -235,75 +128,66 @@ class XEP_0054(object):
mime_type = None
else:
if not mime_type:
# MIME type not know, we'll only support PNG files
# TODO: autodetection using e.g. "magic" module
# (https://pypi.org/project/python-magic/)
# MIME type not known, we'll try autodetection below
mime_type = None
elif mime_type not in ("image/gif", "image/jpeg", "image/png"):
if mime_type == "image/x-png":
# XXX: this old MIME type is still used by some clients
mime_type = "image/png"
else:
# TODO: handle other image formats (svg?)
log.warning(
"following avatar image format is not handled: {type} [{jid}]".format(
type=mime_type, jid=entity_jid.full()
)
)
raise Failure(exceptions.DataError())
elif mime_type == "image/x-png":
# XXX: this old MIME type is still used by some clients
mime_type = "image/png"
ext = mimetypes.guess_extension(mime_type, strict=False)
assert ext is not None
if ext == ".jpe":
ext = ".jpg"
log.debug(
"photo of type {type} with extension {ext} found [{jid}]".format(
type=mime_type, ext=ext, jid=entity_jid.full()
)
)
try:
buf = str(next(photo_elt.elements(NS_VCARD, "BINVAL")))
except StopIteration:
log.warning("BINVAL element not found")
raise Failure(exceptions.NotFound())
if not buf:
log.warning("empty avatar for {jid}".format(jid=entity_jid.full()))
raise Failure(exceptions.NotFound())
if mime_type is None:
log.warning(_("no MIME type found for {entity}'s avatar, assuming image/png")
.format(entity=entity_jid.full()))
if buf[:8] != b'\x89\x50\x4e\x47\x0d\x0a\x1a\x0a':
log.warning("this is not a PNG file, ignoring it")
raise Failure(exceptions.DataError())
else:
mime_type = "image/png"
log.debug(_("Decoding binary"))
decoded = b64decode(buf)
del buf
if mime_type is None:
log.debug(
f"no media type found specified for {entity_jid}'s avatar, trying to "
f"guess")
try:
mime_type = image.guess_type(io.BytesIO(decoded))
except IOError as e:
log.warning(f"Can't open avatar buffer: {e}")
if mime_type is None:
msg = f"Can't find media type for {entity_jid}'s avatar"
log.warning(msg)
raise Failure(exceptions.DataError(msg))
image_hash = sha1(decoded).hexdigest()
with client.cache.cacheData(
with self.host.common_cache.cacheData(
PLUGIN_INFO["import_name"],
image_hash,
mime_type,
# we keep in cache for 1 year
MAX_AGE,
) as f:
f.write(decoded)
return image_hash
@defer.inlineCallbacks
def vCard2Dict(self, client, vcard, entity_jid):
"""Convert a VCard to a dict, and save binaries"""
log.debug(("parsing vcard"))
async def vCard2Dict(self, client, vcard_elt, entity_jid):
"""Convert a VCard_elt to a dict, and save binaries"""
log.debug(("parsing vcard_elt"))
vcard_dict = {}
for elem in vcard.elements():
for elem in vcard_elt.elements():
if elem.name == "FN":
vcard_dict["fullname"] = str(elem)
elif elem.name == "NICKNAME":
vcard_dict["nick"] = str(elem)
self.updateCache(client, entity_jid, "nick", vcard_dict["nick"])
nickname = vcard_dict["nickname"] = str(elem)
await self._i.update(
client,
"nicknames",
[nickname],
entity_jid
)
elif elem.name == "URL":
vcard_dict["website"] = str(elem)
elif elem.name == "EMAIL":
@ -313,193 +197,144 @@ class XEP_0054(object):
elif elem.name == "PHOTO":
# TODO: handle EXTVAL
try:
avatar_hash = yield threads.deferToThread(
avatar_hash = await threads.deferToThread(
self.savePhoto, client, elem, entity_jid
)
except (exceptions.DataError, exceptions.NotFound):
avatar_hash = ""
vcard_dict["avatar"] = avatar_hash
except Exception as e:
log.error("avatar saving error: {}".format(e))
log.error(f"avatar saving error: {e}")
avatar_hash = None
else:
vcard_dict["avatar"] = avatar_hash
self.updateCache(client, entity_jid, "avatar", avatar_hash)
if avatar_hash is not None:
await client._xep_0054_avatar_hashes.aset(
entity_jid.full(), avatar_hash)
if avatar_hash:
avatar_cache = self.host.common_cache.getMetadata(avatar_hash)
await self._i.update(
client,
"avatar",
{
'path': avatar_cache['path'],
'media_type': avatar_cache['mime_type'],
'cache_uid': avatar_hash
},
entity_jid
)
else:
await self._i.update(client, "avatar", None, entity_jid)
else:
log.debug("FIXME: [{}] VCard tag is not managed yet".format(elem.name))
log.debug("FIXME: [{}] VCard_elt tag is not managed yet".format(elem.name))
# if a data in cache doesn't exist anymore, we need to delete it
# so we check CACHED_DATA no gotten (i.e. not in vcard_dict keys)
# and we reset them
for datum in CACHED_DATA.difference(list(vcard_dict.keys())):
log.debug(
"reseting vcard datum [{datum}] for {entity}".format(
datum=datum, entity=entity_jid.full()
)
)
self.updateCache(client, entity_jid, datum, None)
return vcard_dict
defer.returnValue(vcard_dict)
async def getVCardElement(self, client, entity_jid):
"""Retrieve domish.Element of a VCard
def _vCardCb(self, vcard_elt, to_jid, client):
"""Called after the first get IQ"""
log.debug(_("VCard found"))
iq_elt = vcard_elt.parent
@param entity_jid(jid.JID): entity from who we need the vCard
@raise DataError: we got an invalid answer
"""
iq_elt = client.IQ("get")
iq_elt["from"] = client.jid.full()
iq_elt["to"] = entity_jid.full()
iq_elt.addElement("vCard", NS_VCARD)
iq_ret_elt = await iq_elt.send(entity_jid.full())
try:
from_jid = jid.JID(iq_elt["from"])
except KeyError:
from_jid = client.jid.userhostJID()
d = self.vCard2Dict(client, vcard_elt, from_jid)
return d
return next(iq_ret_elt.elements(NS_VCARD, "vCard"))
except StopIteration:
log.warning(_(
"vCard element not found for {entity_jid}: {xml}"
).format(entity_jid=entity_jid, xml=iq_ret_elt.toXml()))
raise exceptions.DataError(f"no vCard element found for {entity_jid}")
def _vCardEb(self, failure_, to_jid, client):
"""Called when something is wrong with registration"""
log.warning(
"Can't get vCard for {jid}: {failure}".format(
jid=to_jid.full, failure=failure_
)
)
self.updateCache(client, to_jid, "avatar", None)
async def updateVCardElt(self, client, entity_jid, to_replace):
"""Create a vcard element to replace some metadata
def _getVcardElt(self, iq_elt):
return next(iq_elt.elements(NS_VCARD, "vCard"))
def getCardRaw(self, client, entity_jid):
"""get raw vCard XML
params are as in [getCard]
@param to_replace(list[str]): list of vcard element names to remove
"""
entity_jid = self.getBareOrFull(client, entity_jid)
log.debug("Asking for {}'s VCard".format(entity_jid.full()))
reg_request = client.IQ("get")
reg_request["from"] = client.jid.full()
reg_request["to"] = entity_jid.full()
reg_request.addElement("vCard", NS_VCARD)
d = reg_request.send(entity_jid.full())
d.addCallback(self._getVcardElt)
return d
def getCard(self, client, entity_jid):
"""Ask server for VCard
@param entity_jid(jid.JID): jid from which we want the VCard
@result: id to retrieve the profile
"""
d = self.getCardRaw(client, entity_jid)
d.addCallbacks(
self._vCardCb,
self._vCardEb,
callbackArgs=[entity_jid, client],
errbackArgs=[entity_jid, client],
)
return d
def _getCardCb(self, __, client, entity):
try:
return client._cache_0054[entity.full()]["avatar"]
except KeyError:
raise Failure(exceptions.NotFound())
def _getAvatar(self, entity, cache_only, hash_only, profile):
client = self.host.getClient(profile)
d = self.getAvatar(client, jid.JID(entity), cache_only, hash_only)
# we need to convert the Path to string
d.addCallback(str)
d.addErrback(lambda __: "")
return d
def getAvatar(self, client, entity, cache_only=True, hash_only=False):
"""get avatar full path or hash
if avatar is not in local cache, it will be requested to the server
@param entity(jid.JID): entity to get avatar from
@param cache_only(bool): if False, will request vCard if avatar is
not in cache
@param hash_only(bool): if True only return hash, not full path
@raise exceptions.NotFound: no avatar found
"""
if not entity.resource and self.isRoom(client, entity):
raise exceptions.NotFound
entity = self.getBareOrFull(client, entity)
full_path = None
try:
# we first check if we have avatar in cache
avatar_hash = client._cache_0054[entity.full()]["avatar"]
if avatar_hash:
# avatar is known and exists
full_path = client.cache.getFilePath(avatar_hash)
if full_path is None:
# cache file is not available (probably expired)
raise KeyError
else:
# avatar has already been checked but it is not set
full_path = ""
except KeyError:
# avatar is not in cache
if cache_only:
return defer.fail(Failure(exceptions.NotFound()))
# we request vCard to get avatar
d = self.getCard(client, entity)
d.addCallback(self._getCardCb, client, entity)
else:
# avatar is in cache, we can return hash
d = defer.succeed(avatar_hash)
if not hash_only:
# full path is requested
if full_path is None:
d.addCallback(client.cache.getFilePath)
else:
d.addCallback(lambda __: full_path)
return d
@defer.inlineCallbacks
def getNick(self, client, entity):
"""get nick from cache, or check vCard
@param entity(jid.JID): entity to get nick from
@return(unicode, None): nick or None if not found
"""
nick = self.getCache(client, entity, "nick")
if nick is not None:
defer.returnValue(nick)
yield self.getCard(client, entity)
defer.returnValue(self.getCache(client, entity, "nick"))
@defer.inlineCallbacks
def setNick(self, client, nick):
"""update our vCard and set a nickname
@param nick(unicode): new nickname to use
"""
jid_ = client.jid.userhostJID()
try:
vcard_elt = yield self.getCardRaw(client, jid_)
# we first check if a vcard already exists, to keep data
vcard_elt = await self.getVCardElement(client, entity_jid)
except error.StanzaError as e:
if e.condition == "item-not-found":
vcard_elt = domish.Element((NS_VCARD, "vCard"))
else:
raise e
try:
nickname_elt = next(vcard_elt.elements(NS_VCARD, "NICKNAME"))
except StopIteration:
pass
except exceptions.DataError:
vcard_elt = domish.Element((NS_VCARD, "vCard"))
else:
vcard_elt.children.remove(nickname_elt)
# the vcard exists, we need to remove elements that we'll replace
for elt_name in to_replace:
try:
elt = next(vcard_elt.elements(NS_VCARD, elt_name))
except StopIteration:
pass
else:
vcard_elt.children.remove(elt)
nickname_elt = vcard_elt.addElement((NS_VCARD, "NICKNAME"), content=nick)
iq_elt = client.IQ()
vcard_elt = iq_elt.addChild(vcard_elt)
yield iq_elt.send()
self.updateCache(client, jid_, "nick", str(nick))
return vcard_elt
def _buildSetAvatar(self, client, vcard_elt, file_path):
async def getCard(self, client, entity_jid):
"""Ask server for VCard
@param entity_jid(jid.JID): jid from which we want the VCard
@result(dict): vCard data
"""
entity_jid = self._i.getIdentityJid(client, entity_jid)
log.debug(f"Asking for {entity_jid}'s VCard")
try:
vcard_elt = await self.getVCardElement(client, entity_jid)
except exceptions.DataError:
self._i.update(client, "avatar", None, entity_jid)
except Exception as e:
log.warning(_(
"Can't get vCard for {entity_jid}: {e}"
).format(entity_jid=entity_jid, e=e))
else:
log.debug(_("VCard found"))
return await self.vCard2Dict(client, vcard_elt, entity_jid)
async def getAvatar(self, client, entity_jid):
"""Get avatar full path or hash
if avatar is not in local cache, it will be requested to the server
@param entity(jid.JID): entity to get avatar from
"""
entity_jid = self._i.getIdentityJid(client, entity_jid)
hashes_cache = client._xep_0054_avatar_hashes
try:
avatar_hash = hashes_cache[entity_jid.full()]
except KeyError:
log.debug(f"avatar for {entity_jid} is not in cache, we retrieve it")
vcard = await self.getCard(client, entity_jid)
if vcard is None:
return None
avatar_hash = hashes_cache[entity_jid.full()]
if not avatar_hash:
return None
avatar_cache = self.host.common_cache.getMetadata(avatar_hash)
if avatar_cache is None:
log.debug("avatar is no more in cache, we re-download it")
vcard = await self.getCard(client, entity_jid)
if vcard is None:
return None
avatar_cache = self.host.common_cache.getMetadata(avatar_hash)
return self._i.avatarBuildMetadata(
avatar_cache['path'], avatar_cache['mime_type'], avatar_hash)
def _buildSetAvatar(self, client, vcard_elt, avatar_data):
# XXX: this method is executed in a separate thread
try:
img = Image.open(file_path)
except IOError:
return Failure(exceptions.DataError("Can't open image"))
img = Image.open(avatar_data['path'])
except IOError as e:
raise exceptions.DataError(f"Can't open image: {e}")
if img.size != AVATAR_DIM:
img.thumbnail(AVATAR_DIM)
@ -519,52 +354,65 @@ class XEP_0054(object):
photo_elt = vcard_elt.addElement("PHOTO")
photo_elt.addElement("TYPE", content="image/png")
image_b64 = b64encode(img_buf.getvalue()).decode('utf-8')
image_b64 = b64encode(img_buf.getvalue()).decode()
photo_elt.addElement("BINVAL", content=image_b64)
image_hash = sha1(img_buf.getvalue()).hexdigest()
with client.cache.cacheData(
PLUGIN_INFO["import_name"], image_hash, "image/png", MAX_AGE
with self.host.common_cache.cacheData(
PLUGIN_INFO["import_name"], image_hash, "image/png"
) as f:
f.write(img_buf.getvalue())
avatar_data['path'] = Path(f.name)
avatar_data['cache_uid'] = image_hash
return image_hash
def _setAvatar(self, file_path, profile_key=C.PROF_KEY_NONE):
client = self.host.getClient(profile_key)
return self.setAvatar(client, file_path)
@defer.inlineCallbacks
def setAvatar(self, client, file_path):
async def setAvatar(self, client, avatar_data, entity):
"""Set avatar of the profile
@param file_path: path of the image of the avatar
@param avatar_data(dict): data of the image to use as avatar, as built by
IDENTITY plugin.
@param entity(jid.JID): entity whose avatar must be changed
"""
try:
# we first check if a vcard already exists, to keep data
vcard_elt = yield self.getCardRaw(client, client.jid.userhostJID())
except error.StanzaError as e:
if e.condition == "item-not-found":
vcard_elt = domish.Element((NS_VCARD, "vCard"))
else:
raise e
else:
# the vcard exists, we need to remove PHOTO element as we'll make a new one
try:
photo_elt = next(vcard_elt.elements(NS_VCARD, "PHOTO"))
except StopIteration:
pass
else:
vcard_elt.children.remove(photo_elt)
vcard_elt = await self.updateVCardElt(client, entity, ['PHOTO'])
iq_elt = client.IQ()
iq_elt.addChild(vcard_elt)
image_hash = yield threads.deferToThread(
self._buildSetAvatar, client, vcard_elt, file_path
await threads.deferToThread(
self._buildSetAvatar, client, vcard_elt, avatar_data
)
# image is now at the right size/format
self.updateCache(client, client.jid.userhostJID(), "avatar", image_hash)
yield iq_elt.send()
client.presence.available() # FIXME: should send the current presence, not always "available" !
await iq_elt.send()
# FIXME: should send the current presence, not always "available" !
await client.presence.available()
async def getNicknames(self, client, entity):
"""get nick from cache, or check vCard
@param entity(jid.JID): entity to get nick from
@return(list[str]): nicknames found
"""
vcard_data = await self.getCard(client, entity)
try:
return [vcard_data['nickname']]
except (KeyError, TypeError):
return []
async def setNicknames(self, client, nicknames, entity):
"""Update our vCard and set a nickname
@param nicknames(list[str]): new nicknames to use
only first item of this list will be used here
"""
nick = nicknames[0].strip()
vcard_elt = await self.updateVCardElt(client, entity, ['NICKNAME'])
if nick:
vcard_elt.addElement((NS_VCARD, "NICKNAME"), content=nick)
iq_elt = client.IQ()
iq_elt.addChild(vcard_elt)
await iq_elt.send()
@implementer(iwokkel.IDisco)
@ -575,7 +423,7 @@ class XEP_0054_handler(XMPPHandler):
self.host = plugin_parent.host
def connectionInitialized(self):
self.xmlstream.addObserver(VCARD_UPDATE, self.update)
self.xmlstream.addObserver(VCARD_UPDATE, self._update)
def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
return [disco.DiscoFeature(NS_VCARD)]
@ -583,11 +431,11 @@ class XEP_0054_handler(XMPPHandler):
def getDiscoItems(self, requestor, target, nodeIdentifier=""):
return []
def _checkAvatarHash(self, __, client, entity, given_hash):
"""check that hash in cash (i.e. computed hash) is the same as given one"""
# XXX: if they differ, the avater will be requested on each connection
def _checkAvatarHash(self, client, entity, given_hash):
"""Check that hash in cache (i.e. computed hash) is the same as given one"""
# XXX: if they differ, the avatar will be requested on each connection
# TODO: try to avoid re-requesting avatar in this case
computed_hash = self.plugin_parent.getCache(client, entity, "avatar")
computed_hash = client._xep_0054_avatar_hashes[entity.full]
if computed_hash != given_hash:
log.warning(
"computed hash differs from given hash for {entity}:\n"
@ -596,15 +444,16 @@ class XEP_0054_handler(XMPPHandler):
)
)
def update(self, presence):
async def update(self, presence):
"""Called on <presence/> stanza with vcard data
Check for avatar information, and get VCard if needed
@param presend(domish.Element): <presence/> stanza
@param presence(domish.Element): <presence/> stanza
"""
client = self.parent
entity_jid = self.plugin_parent.getBareOrFull(client, jid.JID(presence["from"]))
# FIXME: wokkel's data_form should be used here
entity_jid = self.plugin_parent._i.getIdentityJid(
client, jid.JID(presence["from"]))
try:
x_elt = next(presence.elements(NS_VCARD_UPDATE, "x"))
except StopIteration:
@ -615,45 +464,66 @@ class XEP_0054_handler(XMPPHandler):
except StopIteration:
return
hash_ = str(photo_elt).strip()
if hash_ == C.HASH_SHA1_EMPTY:
hash_ = ""
old_avatar = self.plugin_parent.getCache(client, entity_jid, "avatar")
new_hash = str(photo_elt).strip()
if new_hash == HASH_SHA1_EMPTY:
new_hash = ""
if old_avatar == hash_:
# no change, we can return...
if hash_:
# ...but we double check that avatar is in cache
file_path = client.cache.getFilePath(hash_)
if file_path is None:
log.error(
"Avatar for [{}] should be in cache but it is not! We get it".format(
entity_jid.full()
)
hashes_cache = client._xep_0054_avatar_hashes
old_hash = hashes_cache.get(entity_jid.full())
if old_hash == new_hash:
# no change, we can return…
if new_hash:
# …but we double check that avatar is in cache
avatar_cache = self.host.common_cache.getMetadata(new_hash)
if avatar_cache is None:
log.debug(
f"Avatar for [{entity_jid}] is known but not in cache, we get "
f"it"
)
self.plugin_parent.getCard(client, entity_jid)
else:
log.debug("avatar for {} already in cache".format(entity_jid.full()))
# getCard will put the avatar in cache
await self.plugin_parent.getCard(client, entity_jid)
else:
log.debug(f"avatar for {entity_jid} is already in cache")
return
if not hash_:
# the avatar has been removed
# XXX: we use empty string instead of None to indicate that we took avatar
# but it is empty on purpose
self.plugin_parent.updateCache(client, entity_jid, "avatar", "")
if new_hash is None:
# XXX: we use empty string to indicate that there is no avatar
new_hash = ""
await hashes_cache.aset(entity_jid.full(), new_hash)
if not new_hash:
await self.plugin_parent._i.update(
client, "avatar", None, entity_jid)
# the avatar has been removed, no need to go further
return
file_path = client.cache.getFilePath(hash_)
if file_path is not None:
avatar_cache = self.host.common_cache.getMetadata(new_hash)
if avatar_cache is not None:
log.debug(
"New avatar found for [{}], it's already in cache, we use it".format(
entity_jid.full()
)
f"New avatar found for [{entity_jid}], it's already in cache, we use it"
)
await self.plugin_parent._i.update(
client,
"avatar",
{
'path': avatar_cache['path'],
'media_type': avatar_cache['mime_type'],
'cache_uid': new_hash,
},
entity_jid
)
self.plugin_parent.updateCache(client, entity_jid, "avatar", hash_)
else:
log.debug(
"New avatar found for [{}], requesting vcard".format(entity_jid.full())
"New avatar found for [{entity_jid}], requesting vcard"
)
d = self.plugin_parent.getCard(client, entity_jid)
d.addCallback(self._checkAvatarHash, client, entity_jid, hash_)
vcard = await self.plugin_parent.getCard(client, entity_jid)
if vcard is None:
log.warning(f"Unexpected empty vCard for {entity_jid}")
return
await self._checkAvatarHash(client, entity_jid, new_hash)
def _update(self, presence):
defer.ensureDeferred(self.update(presence))

View file

@ -145,11 +145,12 @@ class XEP_0085(object):
@param value: True, False or DELETE_VALUE to delete the entity data
@param profile: current profile
"""
client = self.host.getClient(profile)
if value == DELETE_VALUE:
self.host.memory.delEntityDatum(entity_jid, ENTITY_KEY, profile)
self.host.memory.delEntityDatum(client, entity_jid, ENTITY_KEY)
else:
self.host.memory.updateEntityData(
entity_jid, ENTITY_KEY, value, profile_key=profile
client, entity_jid, ENTITY_KEY, value
)
if not value or value == DELETE_VALUE:
# reinit chat state UI for this or these contact(s)
@ -256,9 +257,10 @@ class XEP_0085(object):
@param profile (str): %(doc_profile)s
@return: bool
"""
client = self.host.getClient(profile)
try:
type_ = self.host.memory.getEntityDatum(
to_jid.userhostJID(), C.ENTITY_TYPE, profile)
client, to_jid.userhostJID(), C.ENTITY_TYPE)
if type_ == C.ENTITY_TYPE_MUC:
return True
except (exceptions.UnknownEntityError, KeyError):
@ -273,6 +275,7 @@ class XEP_0085(object):
@param: current profile
@return: True if the notifications should be sent to this JID.
"""
client = self.host.getClient(profile)
# check if the parameter is active
if not self.host.memory.getParamA(PARAM_NAME, PARAM_KEY, profile_key=profile):
return False
@ -282,7 +285,7 @@ class XEP_0085(object):
# FIXME: this assertion crash when we want to send a message to an online bare jid
# assert to_jid.resource or not self.host.memory.isEntityAvailable(to_jid, profile) # must either have a resource, or talk to an offline contact
try:
return self.host.memory.getEntityDatum(to_jid, ENTITY_KEY, profile)
return self.host.memory.getEntityDatum(client, to_jid, ENTITY_KEY)
except (exceptions.UnknownEntityError, KeyError):
if forceEntityData:
# enable it for the first time

View file

@ -60,7 +60,7 @@ class XEP_0115(object):
host.trigger.add("Presence send", self._presenceTrigger)
def getHandler(self, client):
return XEP_0115_handler(self, client.profile)
return XEP_0115_handler(self)
@defer.inlineCallbacks
def _prepareCaps(self, client):
@ -97,7 +97,7 @@ class XEP_0115(object):
if cap_hash not in self.host.memory.disco.hashes:
self.host.memory.disco.hashes[cap_hash] = disco_infos
self.host.memory.updateEntityData(
client.jid, C.ENTITY_CAP_HASH, cap_hash, profile_key=client.profile
client, client.jid, C.ENTITY_CAP_HASH, cap_hash
)
def _presenceAddElt(self, client, obj):
@ -118,10 +118,13 @@ class XEP_0115(object):
@implementer(iwokkel.IDisco)
class XEP_0115_handler(XMPPHandler):
def __init__(self, plugin_parent, profile):
def __init__(self, plugin_parent):
self.plugin_parent = plugin_parent
self.host = plugin_parent.host
self.profile = profile
@property
def client(self):
return self.parent
def connectionInitialized(self):
self.xmlstream.addObserver(CAPABILITY_UPDATE, self.update)
@ -158,7 +161,7 @@ class XEP_0115_handler(XMPPHandler):
% {"hash": c_ver, "jid": from_jid.full()}
)
self.host.memory.updateEntityData(
from_jid, C.ENTITY_CAP_HASH, c_ver, profile_key=self.profile
self.client, from_jid, C.ENTITY_CAP_HASH, c_ver
)
return
@ -173,7 +176,7 @@ class XEP_0115_handler(XMPPHandler):
def cb(__):
computed_hash = self.host.memory.getEntityDatum(
from_jid, C.ENTITY_CAP_HASH, self.profile
self.client, from_jid, C.ENTITY_CAP_HASH
)
if computed_hash != c_ver:
log.warning(

View file

@ -288,12 +288,13 @@ class ContactList(object):
@param profile: %(doc_profile)s
@return dict
"""
client = self.host.getClient(profile)
if C.bool(data.get("cancelled", "false")):
return {}
contact_jid = jid.JID(data[xml_tools.formEscape("contact_jid")])
# TODO: replace '\t' by a constant (see tools.xmlui.XMLUI.onFormSubmitted)
groups = data[xml_tools.formEscape("groups_list")].split("\t")
self.host.updateContact(contact_jid, name="", groups=groups, profile_key=profile)
self.host.updateContact(client, contact_jid, name="", groups=groups)
return {}
def _deleteContact(self, contact_jid, profile):

View file

@ -208,6 +208,15 @@ class Bridge:
error_handler = lambda err:errback(dbus_to_bridge_exception(err))
return self.db_core_iface.connect(profile_key, password, options, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler)
def contactGet(self, arg_0, profile_key="@DEFAULT@", callback=None, errback=None):
if callback is None:
error_handler = None
else:
if errback is None:
errback = log.error
error_handler = lambda err:errback(dbus_to_bridge_exception(err))
return self.db_core_iface.contactGet(arg_0, profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler)
def delContact(self, entity_jid, profile_key="@DEFAULT@", callback=None, errback=None):
if callback is None:
error_handler = None
@ -883,7 +892,12 @@ class Bridge:
if errback is None:
errback = log.error
error_handler = lambda err:errback(dbus_to_bridge_exception(err))
return self.db_core_iface.updateContact(entity_jid, name, groups, profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler)
kwargs={}
if callback is not None:
kwargs['timeout'] = const_TIMEOUT
kwargs['reply_handler'] = callback
kwargs['error_handler'] = error_handler
return self.db_core_iface.updateContact(entity_jid, name, groups, profile_key, **kwargs)
class AIOBridge(Bridge):
@ -975,6 +989,14 @@ class AIOBridge(Bridge):
self.db_core_iface.connect(profile_key, password, options, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
return fut
def contactGet(self, arg_0, profile_key="@DEFAULT@"):
loop = asyncio.get_running_loop()
fut = loop.create_future()
reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
self.db_core_iface.contactGet(arg_0, profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
return fut
def delContact(self, entity_jid, profile_key="@DEFAULT@"):
loop = asyncio.get_running_loop()
fut = loop.create_future()

View file

@ -1,7 +1,6 @@
#!/usr/bin/env python3
# SAT communication bridge
# SàT communication bridge
# Copyright (C) 2009-2020 Jérôme Poisson (goffi@goffi.org)
# This program is free software: you can redistribute it and/or modify
@ -206,6 +205,15 @@ class Bridge(object):
else:
d.addErrback(self._errback, ori_errback=errback)
def contactGet(self, arg_0, profile_key="@DEFAULT@", callback=None, errback=None):
d = self.root.callRemote("contactGet", arg_0, profile_key)
if callback is not None:
d.addCallback(callback)
if errback is None:
d.addErrback(self._generic_errback)
else:
d.addErrback(self._errback, ori_errback=errback)
def delContact(self, entity_jid, profile_key="@DEFAULT@", callback=None, errback=None):
d = self.root.callRemote("delContact", entity_jid, profile_key)
if callback is not None:
@ -788,6 +796,11 @@ class AIOBridge(Bridge):
d.addErrback(self._errback)
return d.asFuture(asyncio.get_event_loop())
def contactGet(self, arg_0, profile_key="@DEFAULT@"):
d = self.root.callRemote("contactGet", arg_0, profile_key)
d.addErrback(self._errback)
return d.asFuture(asyncio.get_event_loop())
def delContact(self, entity_jid, profile_key="@DEFAULT@"):
d = self.root.callRemote("delContact", entity_jid, profile_key)
d.addErrback(self._errback)

Some files were not shown because too many files have changed in this diff Show more