diff src/memory/disco.py @ 944:e1842ebcb2f3

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
author Goffi <goffi@goffi.org>
date Fri, 28 Mar 2014 18:07:22 +0100
parents src/plugins/plugin_xep_0115.py@c6d8fc63b1db
children 027a054c6dda
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/memory/disco.py	Fri Mar 28 18:07:22 2014 +0100
@@ -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