core, plugin XEP-0115: discovery refactoring:
- hashing algorithm of XEP-0115 has been including in core
- our own hash is still calculated by XEP-0115 and can be regenerated with XEP_0115.recalculateHash
- old discovery methods have been removed. Now the following methods are used:
- hasFeature: tell if a feature is available for an entity
- getDiscoInfos: self explaining
- getDiscoItems: self explaining
- findServiceEntities: return all available items of an entity which given (category, type)
- findFeaturesSet: search for a set of features in entity + entity's items
all these methods are asynchronous, and manage cache automatically
- XEP-0115 manage in a better way hashes, and now use a trigger for presence instead of monkey patch
- new FeatureNotFound exception, when we want to do something which is not available
- refactored client initialisation sequence, removed client.initialized Deferred
- added constant APP_URL
- test_plugin_xep_0033.py has been temporarly deactivated, the time to adapt it
- lot of cleaning
This commit is contained in:
parent
382d49a628
commit
95678d7967
3 changed files with 431 additions and 479 deletions
197
src/memory/disco.py
Executable file
197
src/memory/disco.py
Executable file
|
|
@ -0,0 +1,197 @@
|
|||
#!/usr/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# SAT: a jabber client
|
||||
# Copyright (C) 2009, 2010, 2011, 2012, 2013, 2014 Jérôme Poisson (goffi@goffi.org)
|
||||
|
||||
# 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
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
|
||||
# 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 sat.core.i18n import _
|
||||
from logging import debug, info, warning, error
|
||||
from twisted.words.protocols.jabber import jid
|
||||
from twisted.internet import defer
|
||||
from sat.core.constants import Const as C
|
||||
from wokkel import disco
|
||||
from base64 import b64encode
|
||||
from hashlib import sha1
|
||||
|
||||
|
||||
PRESENCE = '/presence'
|
||||
NS_ENTITY_CAPABILITY = 'http://jabber.org/protocol/caps'
|
||||
CAPABILITY_UPDATE = PRESENCE + '/c[@xmlns="' + NS_ENTITY_CAPABILITY + '"]'
|
||||
|
||||
class HashGenerationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ByteIdentity(object):
|
||||
"""This class manage identity as bytes (needed for i;octet sort), it is used for the hash generation"""
|
||||
|
||||
def __init__(self, identity, lang=None):
|
||||
assert isinstance(identity, disco.DiscoIdentity)
|
||||
self.category = identity.category.encode('utf-8')
|
||||
self.idType = identity.type.encode('utf-8')
|
||||
self.name = identity.name.encode('utf-8') if identity.name else ''
|
||||
self.lang = lang.encode('utf-8') if lang is not None else ''
|
||||
|
||||
def __str__(self):
|
||||
return "%s/%s/%s/%s" % (self.category, self.idType, self.lang, self.name)
|
||||
|
||||
|
||||
class Discovery(object):
|
||||
""" Manage capabilities of entities """
|
||||
|
||||
def __init__(self, host):
|
||||
self.host = host
|
||||
self.hashes = {} # key: capability hash, value: disco features/identities
|
||||
# TODO: save hashes in databse, remove legacy hashes
|
||||
|
||||
@defer.inlineCallbacks
|
||||
def hasFeature(self, feature, jid_=None, profile_key=C.PROF_KEY_NONE):
|
||||
"""Tell if an entity has the required feature
|
||||
|
||||
@param feature: feature namespace
|
||||
@param jid_: jid of the target, or None for profile's server
|
||||
@param profile_key: %(doc_profile_key)s
|
||||
@return: a Deferred which fire a boolean (True if feature is available)
|
||||
"""
|
||||
disco_info = yield self.getInfos(jid_, profile_key)
|
||||
defer.returnValue(feature in disco_info)
|
||||
|
||||
@defer.inlineCallbacks
|
||||
def getInfos(self, jid_=None, profile_key=C.PROF_KEY_NONE):
|
||||
"""get disco infos from jid_, filling capability hash if needed
|
||||
|
||||
@param jid_: jid of the target, or None for profile's server
|
||||
@param profile_key: %(doc_profile_key)s
|
||||
@return: a Deferred which fire disco.DiscoInfo
|
||||
"""
|
||||
client = self.host.getClient(profile_key)
|
||||
if jid_ is None:
|
||||
jid_ = jid.JID(client.jid.host)
|
||||
try:
|
||||
cap_hash = self.host.memory.getEntityData(jid_, [C.ENTITY_CAP_HASH], client.profile)[C.ENTITY_CAP_HASH]
|
||||
disco_info = self.hashes[cap_hash]
|
||||
except KeyError:
|
||||
# capability hash is not available, we'll compute one
|
||||
disco_info = yield client.disco.requestInfo(jid_)
|
||||
cap_hash = self.generateHash(disco_info)
|
||||
self.hashes[cap_hash] = disco_info
|
||||
yield self.host.memory.updateEntityData(jid_, C.ENTITY_CAP_HASH, cap_hash, client.profile)
|
||||
defer.returnValue(disco_info)
|
||||
|
||||
@defer.inlineCallbacks
|
||||
def getItems(self, jid_=None, profile_key=C.PROF_KEY_NONE):
|
||||
"""get disco items from jid_, cache them for our own server
|
||||
|
||||
@param jid_: jid of the target, or None for profile's server
|
||||
@param profile_key: %(doc_profile_key)s
|
||||
@return: a Deferred which fire disco.DiscoInfo
|
||||
"""
|
||||
client = self.host.getClient(profile_key)
|
||||
if jid_ is None:
|
||||
jid_ = jid.JID(client.jid.host)
|
||||
# we cache items only for our own server
|
||||
try:
|
||||
items = self.host.memory.getEntityData(jid_, ["DISCO_ITEMS"], client.profile)["DISCO_ITEMS"]
|
||||
debug("[%s] disco items are in cache" % jid_.full())
|
||||
except KeyError:
|
||||
debug("Caching [%s] disco items" % jid_.full())
|
||||
items = yield client.disco.requestItems(jid_)
|
||||
self.host.memory.updateEntityData(jid_, "DISCO_ITEMS", items, client.profile)
|
||||
else:
|
||||
items = yield client.disco.requestItems(jid_)
|
||||
|
||||
defer.returnValue(items)
|
||||
|
||||
|
||||
@defer.inlineCallbacks
|
||||
def findServiceEntities(self, category, type_, jid_=None, profile_key=C.PROF_KEY_NONE):
|
||||
"""Return all available items of an entity which correspond to (category, type_)
|
||||
|
||||
@param category: identity's category
|
||||
@param type_: identitiy's type
|
||||
@param jid_: the jid of the target server (None for profile's server)
|
||||
@param profile_key: %(doc_profile_key)s
|
||||
@return: a set of entities or None if no cached data were found
|
||||
"""
|
||||
found_identities = set()
|
||||
items = yield self.getItems(jid_, profile_key)
|
||||
for item in items:
|
||||
infos = yield self.getInfos(item.entity, profile_key)
|
||||
if (category, type_) in infos.identities:
|
||||
found_identities.add(item.entity)
|
||||
|
||||
defer.returnValue(found_identities)
|
||||
|
||||
@defer.inlineCallbacks
|
||||
def findFeaturesSet(self, features, category=None, type_=None, jid_=None, profile_key=C.PROF_KEY_NONE):
|
||||
"""Return entities (including jid_ and its items) offering features
|
||||
|
||||
@param features: iterable of features which must be present
|
||||
@param category: if not None, accept only this category
|
||||
@param type_: if not None, accept only this type
|
||||
@param jid_: the jid of the target server (None for profile's server)
|
||||
@param profile_key: %(doc_profile_key)s
|
||||
@return: a set of found entities
|
||||
"""
|
||||
client = self.host.getClient(profile_key)
|
||||
if jid_ is None:
|
||||
jid_ = jid.JID(client.jid.host)
|
||||
features = set(features)
|
||||
found_entities = set()
|
||||
|
||||
items = yield self.getItems(jid_, profile_key)
|
||||
for entity in [jid_] + items:
|
||||
infos = yield self.getInfos(entity, profile_key)
|
||||
if category is not None or type_ is not None:
|
||||
categories = set()
|
||||
types = set()
|
||||
for identity in infos.identities:
|
||||
id_cat, id_type = identity
|
||||
categories.add(id_cat)
|
||||
types.add(id_type)
|
||||
if category is not None and category not in categories:
|
||||
continue
|
||||
if type_ is not None and type_ not in types:
|
||||
continue
|
||||
if features.issubset(infos.features):
|
||||
found_entities.add(entity)
|
||||
|
||||
defer.returnValue(found_entities)
|
||||
|
||||
def generateHash(self, services):
|
||||
""" Generate a unique hash for given service
|
||||
|
||||
hash algorithm is the one described in XEP-0115
|
||||
@param services: iterable of disco.DiscoIdentity/disco.DiscoFeature, as returned by discoHandler.info
|
||||
|
||||
"""
|
||||
s = []
|
||||
byte_identities = [ByteIdentity(service) for service in services if isinstance(service, disco.DiscoIdentity)] # FIXME: lang must be managed here
|
||||
byte_identities.sort(key=lambda i: i.lang)
|
||||
byte_identities.sort(key=lambda i: i.idType)
|
||||
byte_identities.sort(key=lambda i: i.category)
|
||||
for identity in byte_identities:
|
||||
s.append(str(identity))
|
||||
s.append('<')
|
||||
byte_features = [service.encode('utf-8') for service in services if isinstance(service, disco.DiscoFeature)]
|
||||
byte_features.sort() # XXX: the default sort has the same behaviour as the requested RFC 4790 i;octet sort
|
||||
for feature in byte_features:
|
||||
s.append(feature)
|
||||
s.append('<')
|
||||
#TODO: manage XEP-0128 data form here
|
||||
cap_hash = b64encode(sha1(''.join(s)).digest())
|
||||
debug(_('Capability hash generated: [%s]') % cap_hash)
|
||||
return cap_hash
|
||||
|
|
@ -32,6 +32,7 @@ from sat.core.constants import Const as C
|
|||
from sat.memory.sqlite import SqliteStorage
|
||||
from sat.memory.persistent import PersistentDict
|
||||
from sat.memory.params import Params
|
||||
from sat.memory.disco import Discovery
|
||||
|
||||
|
||||
class Sessions(object):
|
||||
|
|
@ -115,8 +116,7 @@ class Memory(object):
|
|||
self._entities_cache = {} # XXX: keep presence/last resource/other data in cache
|
||||
# /!\ an entity is not necessarily in roster
|
||||
self.subscriptions = {}
|
||||
self.server_features = {} # used to store discovery's informations
|
||||
self.server_identities = {}
|
||||
self.disco = Discovery(host)
|
||||
self.config = self.parseMainConf()
|
||||
self.__fixLocalDir()
|
||||
database_file = os.path.expanduser(os.path.join(self.getConfig('', 'local_dir'), C.SAVEFILE_DATABASE))
|
||||
|
|
@ -280,93 +280,6 @@ class Memory(object):
|
|||
assert profile != C.PROF_KEY_NONE
|
||||
return self.storage.getHistory(jid.JID(from_jid), jid.JID(to_jid), limit, between, profile)
|
||||
|
||||
def addServerFeature(self, feature, jid_, profile):
|
||||
"""Add a feature discovered from server
|
||||
@param feature: string of the feature
|
||||
@param jid_: the jid of the target server
|
||||
@param profile: which profile asked this server?"""
|
||||
if profile not in self.server_features:
|
||||
self.server_features[profile] = {}
|
||||
features = self.server_features[profile].setdefault(jid_, [])
|
||||
features.append(feature)
|
||||
|
||||
def addServerIdentity(self, category, type_, entity, jid_, profile):
|
||||
"""Add an identity discovered from server
|
||||
@param feature: string of the feature
|
||||
@param jid_: the jid of the target server
|
||||
@param profile: which profile asked this server?"""
|
||||
if not profile in self.server_identities:
|
||||
self.server_identities[profile] = {}
|
||||
identities = self.server_identities[profile].setdefault(jid_, {})
|
||||
if (category, type_) not in identities:
|
||||
identities[(category, type_)] = set()
|
||||
identities[(category, type_)].add(entity)
|
||||
|
||||
def getServerServiceEntities(self, category, type_, jid_=None, profile=None):
|
||||
"""Return all available entities of a server for the service (category, type_)
|
||||
@param category: identity's category
|
||||
@param type_: identitiy's type
|
||||
@param jid_: the jid of the target server (None for profile's server)
|
||||
@param profile: which profile is asking this server?
|
||||
@return: a set of entities or None if no cached data were found
|
||||
"""
|
||||
if jid_ is None:
|
||||
jid_ = self.host.getClientHostJid(profile)
|
||||
if profile in self.server_identities and jid_ in self.server_identities[profile]:
|
||||
return self.server_identities[profile][jid_].get((category, type_), set())
|
||||
else:
|
||||
return None
|
||||
|
||||
def getServerServiceEntity(self, category, type_, jid_=None, profile=None):
|
||||
"""Helper method to get first available entity of a server for the service (category, type_)
|
||||
@param category: identity's category
|
||||
@param type_: identitiy's type
|
||||
@param jid_: the jid of the target server (None for profile's server)
|
||||
@param profile: which profile is asking this server?
|
||||
@return: the first found entity or None if no cached data were found
|
||||
"""
|
||||
entities = self.getServerServiceEntities(category, type_, jid_, profile)
|
||||
if entities is None:
|
||||
warning(_("Entities (%(category)s/%(type)s) of %(server)s not available, maybe they haven't been asked yet?")
|
||||
% {"category": category, "type": type_, "server": jid_})
|
||||
return None
|
||||
else:
|
||||
return list(entities)[0] if entities else None
|
||||
|
||||
def getAllServerIdentities(self, jid_, profile):
|
||||
"""Helper method to get all identities of a server
|
||||
@param jid_: the jid of the target server (None for profile's server)
|
||||
@param profile: which profile is asking this server?
|
||||
@return: a set of entities or None if no cached data were found
|
||||
"""
|
||||
if jid_ is None:
|
||||
jid_ = self.host.getClientHostJid(profile)
|
||||
if jid_ not in self.server_identities[profile]:
|
||||
return None
|
||||
entities = set()
|
||||
for set_ in self.server_identities[profile][jid_].values():
|
||||
entities.update(set_)
|
||||
return entities
|
||||
|
||||
def hasServerFeature(self, feature, jid_=None, profile_key=C.PROF_KEY_NONE):
|
||||
"""Tell if the specified server has the required feature
|
||||
@param feature: requested feature
|
||||
@param jid_: the jid of the target server (None for profile's server)
|
||||
@param profile_key: %(doc_profile_key)s
|
||||
"""
|
||||
profile = self.getProfileName(profile_key)
|
||||
if not profile:
|
||||
error(_('Trying find server feature for a non-existant profile'))
|
||||
return None
|
||||
assert profile in self.server_features
|
||||
if jid_ is None:
|
||||
jid_ = self.host.getClientHostJid(profile)
|
||||
if jid_ in self.server_features[profile]:
|
||||
return feature in self.server_features[profile][jid_]
|
||||
else:
|
||||
warning(_("Features of %s not available, maybe they haven't been asked yet?") % jid_)
|
||||
return None
|
||||
|
||||
def _getLastResource(self, jid_s, profile_key):
|
||||
jid_ = jid.JID(jid_s)
|
||||
return self.getLastResource(jid_, profile_key) or ""
|
||||
|
|
@ -484,8 +397,9 @@ class Memory(object):
|
|||
|
||||
def getEntityData(self, entity_jid, keys_list, profile_key):
|
||||
"""Get a list of cached values for entity
|
||||
|
||||
@param entity_jid: JID of the entity
|
||||
@param keys_list: list of keys to get, empty list for everything
|
||||
@param keys_list (iterable): list of keys to get, empty list for everything
|
||||
@param profile_key: %(doc_profile_key)s
|
||||
@return: dict withs values for each key in keys_list.
|
||||
if there is no value of a given key, resulting dict will
|
||||
|
|
@ -504,6 +418,20 @@ class Memory(object):
|
|||
ret[key] = entity_data[key]
|
||||
return ret
|
||||
|
||||
def getEntityDatum(self, entity_jid, key, profile_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
|
||||
@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]
|
||||
|
||||
|
||||
def delEntityCache(self, entity_jid, delete_all_resources=True, profile_key=C.PROF_KEY_NONE):
|
||||
"""Remove cached data for entity
|
||||
@param entity_jid: JID of the entity to delete
|
||||
|
|
|
|||
|
|
@ -146,6 +146,7 @@ class Params(object):
|
|||
|
||||
def asyncDeleteProfile(self, profile, force=False):
|
||||
"""Delete an existing profile
|
||||
|
||||
@param profile: name of the profile
|
||||
@param force: force the deletion even if the profile is connected.
|
||||
To be used for direct calls only (not through the bridge).
|
||||
|
|
@ -164,6 +165,7 @@ class Params(object):
|
|||
|
||||
def getProfileName(self, profile_key, return_profile_keys = False):
|
||||
"""return profile according to profile_key
|
||||
|
||||
@param profile_key: profile name or key which can be
|
||||
@ALL@ for all profiles
|
||||
@DEFAULT@ for default profile
|
||||
|
|
|
|||
Loading…
Reference in a new issue