mirror of
https://github.com/correl/SleekXMPP.git
synced 2024-11-24 03:00:15 +00:00
Merge branch 'develop-1.1' into develop
This commit is contained in:
commit
8fd2efa2fa
14 changed files with 1130 additions and 177 deletions
|
@ -79,19 +79,21 @@ class FormField(ElementBase):
|
||||||
reqXML = self.xml.find('{%s}required' % self.namespace)
|
reqXML = self.xml.find('{%s}required' % self.namespace)
|
||||||
return reqXML is not None
|
return reqXML is not None
|
||||||
|
|
||||||
def get_value(self):
|
def get_value(self, convert=True):
|
||||||
valsXML = self.xml.findall('{%s}value' % self.namespace)
|
valsXML = self.xml.findall('{%s}value' % self.namespace)
|
||||||
if len(valsXML) == 0:
|
if len(valsXML) == 0:
|
||||||
return None
|
return None
|
||||||
elif self._type == 'boolean':
|
elif self._type == 'boolean':
|
||||||
return valsXML[0].text in self.true_values
|
if convert:
|
||||||
|
return valsXML[0].text in self.true_values
|
||||||
|
return valsXML[0].text
|
||||||
elif self._type in self.multi_value_types or len(valsXML) > 1:
|
elif self._type in self.multi_value_types or len(valsXML) > 1:
|
||||||
values = []
|
values = []
|
||||||
for valXML in valsXML:
|
for valXML in valsXML:
|
||||||
if valXML.text is None:
|
if valXML.text is None:
|
||||||
valXML.text = ''
|
valXML.text = ''
|
||||||
values.append(valXML.text)
|
values.append(valXML.text)
|
||||||
if self._type == 'text-multi':
|
if self._type == 'text-multi' and condense:
|
||||||
values = "\n".join(values)
|
values = "\n".join(values)
|
||||||
return values
|
return values
|
||||||
else:
|
else:
|
||||||
|
|
|
@ -10,7 +10,7 @@ import logging
|
||||||
|
|
||||||
import sleekxmpp
|
import sleekxmpp
|
||||||
from sleekxmpp import Iq
|
from sleekxmpp import Iq
|
||||||
from sleekxmpp.exceptions import XMPPError
|
from sleekxmpp.exceptions import XMPPError, IqError, IqTimeout
|
||||||
from sleekxmpp.plugins.base import base_plugin
|
from sleekxmpp.plugins.base import base_plugin
|
||||||
from sleekxmpp.xmlstream.handler import Callback
|
from sleekxmpp.xmlstream.handler import Callback
|
||||||
from sleekxmpp.xmlstream.matcher import StanzaPath
|
from sleekxmpp.xmlstream.matcher import StanzaPath
|
||||||
|
@ -108,11 +108,16 @@ class xep_0030(base_plugin):
|
||||||
|
|
||||||
self.static = StaticDisco(self.xmpp)
|
self.static = StaticDisco(self.xmpp)
|
||||||
|
|
||||||
self._disco_ops = ['get_info', 'set_identities', 'set_features',
|
self.use_cache = self.config.get('use_cache', True)
|
||||||
'get_items', 'set_items', 'del_items',
|
self.wrap_results = self.config.get('wrap_results', False)
|
||||||
'add_identity', 'del_identity', 'add_feature',
|
|
||||||
'del_feature', 'add_item', 'del_item',
|
self._disco_ops = [
|
||||||
'del_identities', 'del_features']
|
'get_info', 'set_info', 'set_identities', 'set_features',
|
||||||
|
'get_items', 'set_items', 'del_items', 'add_identity',
|
||||||
|
'del_identity', 'add_feature', 'del_feature', 'add_item',
|
||||||
|
'del_item', 'del_identities', 'del_features', 'cache_info',
|
||||||
|
'get_cached_info', 'supports', 'has_identity']
|
||||||
|
|
||||||
self.default_handlers = {}
|
self.default_handlers = {}
|
||||||
self._handlers = {}
|
self._handlers = {}
|
||||||
for op in self._disco_ops:
|
for op in self._disco_ops:
|
||||||
|
@ -237,7 +242,78 @@ class xep_0030(base_plugin):
|
||||||
self.del_node_handler(op, jid, node)
|
self.del_node_handler(op, jid, node)
|
||||||
self.set_node_handler(op, jid, node, self.default_handlers[op])
|
self.set_node_handler(op, jid, node, self.default_handlers[op])
|
||||||
|
|
||||||
def get_info(self, jid=None, node=None, local=False, **kwargs):
|
def supports(self, jid=None, node=None, feature=None, local=False,
|
||||||
|
cached=True, ifrom=None):
|
||||||
|
"""
|
||||||
|
Check if a JID supports a given feature.
|
||||||
|
|
||||||
|
Return values:
|
||||||
|
True -- The feature is supported
|
||||||
|
False -- The feature is not listed as supported
|
||||||
|
None -- Nothing could be found due to a timeout
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
jid -- Request info from this JID.
|
||||||
|
node -- The particular node to query.
|
||||||
|
feature -- The name of the feature to check.
|
||||||
|
local -- If true, then the query is for a JID/node
|
||||||
|
combination handled by this Sleek instance and
|
||||||
|
no stanzas need to be sent.
|
||||||
|
Otherwise, a disco stanza must be sent to the
|
||||||
|
remove JID to retrieve the info.
|
||||||
|
cached -- If true, then look for the disco info data from
|
||||||
|
the local cache system. If no results are found,
|
||||||
|
send the query as usual. The self.use_cache
|
||||||
|
setting must be set to true for this option to
|
||||||
|
be useful. If set to false, then the cache will
|
||||||
|
be skipped, even if a result has already been
|
||||||
|
cached. Defaults to false.
|
||||||
|
ifrom -- Specifiy the sender's JID.
|
||||||
|
"""
|
||||||
|
data = {'feature': feature,
|
||||||
|
'local': local,
|
||||||
|
'cached': cached}
|
||||||
|
return self._run_node_handler('supports', jid, node, ifrom, data)
|
||||||
|
|
||||||
|
def has_identity(self, jid=None, node=None, category=None, itype=None,
|
||||||
|
lang=None, local=False, cached=True, ifrom=None):
|
||||||
|
"""
|
||||||
|
Check if a JID provides a given identity.
|
||||||
|
|
||||||
|
Return values:
|
||||||
|
True -- The identity is provided
|
||||||
|
False -- The identity is not listed
|
||||||
|
None -- Nothing could be found due to a timeout
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
jid -- Request info from this JID.
|
||||||
|
node -- The particular node to query.
|
||||||
|
category -- The category of the identity to check.
|
||||||
|
itype -- The type of the identity to check.
|
||||||
|
lang -- The language of the identity to check.
|
||||||
|
local -- If true, then the query is for a JID/node
|
||||||
|
combination handled by this Sleek instance and
|
||||||
|
no stanzas need to be sent.
|
||||||
|
Otherwise, a disco stanza must be sent to the
|
||||||
|
remove JID to retrieve the info.
|
||||||
|
cached -- If true, then look for the disco info data from
|
||||||
|
the local cache system. If no results are found,
|
||||||
|
send the query as usual. The self.use_cache
|
||||||
|
setting must be set to true for this option to
|
||||||
|
be useful. If set to false, then the cache will
|
||||||
|
be skipped, even if a result has already been
|
||||||
|
cached. Defaults to false.
|
||||||
|
ifrom -- Specifiy the sender's JID.
|
||||||
|
"""
|
||||||
|
data = {'category': category,
|
||||||
|
'itype': itype,
|
||||||
|
'lang': lang,
|
||||||
|
'local': local,
|
||||||
|
'cached': cached}
|
||||||
|
return self._run_node_handler('has_identity', jid, node, ifrom, data)
|
||||||
|
|
||||||
|
def get_info(self, jid=None, node=None, local=False,
|
||||||
|
cached=None, **kwargs):
|
||||||
"""
|
"""
|
||||||
Retrieve the disco#info results from a given JID/node combination.
|
Retrieve the disco#info results from a given JID/node combination.
|
||||||
|
|
||||||
|
@ -257,6 +333,13 @@ class xep_0030(base_plugin):
|
||||||
no stanzas need to be sent.
|
no stanzas need to be sent.
|
||||||
Otherwise, a disco stanza must be sent to the
|
Otherwise, a disco stanza must be sent to the
|
||||||
remove JID to retrieve the info.
|
remove JID to retrieve the info.
|
||||||
|
cached -- If true, then look for the disco info data from
|
||||||
|
the local cache system. If no results are found,
|
||||||
|
send the query as usual. The self.use_cache
|
||||||
|
setting must be set to true for this option to
|
||||||
|
be useful. If set to false, then the cache will
|
||||||
|
be skipped, even if a result has already been
|
||||||
|
cached. Defaults to false.
|
||||||
ifrom -- Specifiy the sender's JID.
|
ifrom -- Specifiy the sender's JID.
|
||||||
block -- If true, block and wait for the stanzas' reply.
|
block -- If true, block and wait for the stanzas' reply.
|
||||||
timeout -- The time in seconds to block while waiting for
|
timeout -- The time in seconds to block while waiting for
|
||||||
|
@ -266,12 +349,31 @@ class xep_0030(base_plugin):
|
||||||
received instead of blocking and waiting for
|
received instead of blocking and waiting for
|
||||||
the reply.
|
the reply.
|
||||||
"""
|
"""
|
||||||
if local or jid is None:
|
if jid is not None and not isinstance(jid, JID):
|
||||||
|
jid = JID(jid)
|
||||||
|
if self.xmpp.is_component:
|
||||||
|
if jid.domain == self.xmpp.boundjid.domain:
|
||||||
|
local = True
|
||||||
|
else:
|
||||||
|
if str(jid) == str(self.xmpp.boundjid):
|
||||||
|
local = True
|
||||||
|
|
||||||
|
if local or jid in (None, ''):
|
||||||
log.debug("Looking up local disco#info data " + \
|
log.debug("Looking up local disco#info data " + \
|
||||||
"for %s, node %s.", jid, node)
|
"for %s, node %s.", jid, node)
|
||||||
info = self._run_node_handler('get_info', jid, node, kwargs)
|
info = self._run_node_handler('get_info',
|
||||||
return self._fix_default_info(info)
|
jid, node, kwargs.get('ifrom', None), kwargs)
|
||||||
|
info = self._fix_default_info(info)
|
||||||
|
return self._wrap(kwargs.get('ifrom', None), jid, info)
|
||||||
|
|
||||||
|
if cached:
|
||||||
|
log.debug("Looking up cached disco#info data " + \
|
||||||
|
"for %s, node %s.", jid, node)
|
||||||
|
info = self._run_node_handler('get_cached_info',
|
||||||
|
jid, node, kwargs.get('ifrom', None), kwargs)
|
||||||
|
if info is not None:
|
||||||
|
return self._wrap(kwargs.get('ifrom', None), jid, info)
|
||||||
|
|
||||||
iq = self.xmpp.Iq()
|
iq = self.xmpp.Iq()
|
||||||
# Check dfrom parameter for backwards compatibility
|
# Check dfrom parameter for backwards compatibility
|
||||||
iq['from'] = kwargs.get('ifrom', kwargs.get('dfrom', ''))
|
iq['from'] = kwargs.get('ifrom', kwargs.get('dfrom', ''))
|
||||||
|
@ -282,6 +384,15 @@ class xep_0030(base_plugin):
|
||||||
block=kwargs.get('block', True),
|
block=kwargs.get('block', True),
|
||||||
callback=kwargs.get('callback', None))
|
callback=kwargs.get('callback', None))
|
||||||
|
|
||||||
|
def set_info(self, jid=None, node=None, info=None):
|
||||||
|
"""
|
||||||
|
Set the disco#info data for a JID/node based on an existing
|
||||||
|
disco#info stanza.
|
||||||
|
"""
|
||||||
|
if isinstance(info, Iq):
|
||||||
|
info = info['disco_info']
|
||||||
|
self._run_node_handler('set_info', jid, node, None, info)
|
||||||
|
|
||||||
def get_items(self, jid=None, node=None, local=False, **kwargs):
|
def get_items(self, jid=None, node=None, local=False, **kwargs):
|
||||||
"""
|
"""
|
||||||
Retrieve the disco#items results from a given JID/node combination.
|
Retrieve the disco#items results from a given JID/node combination.
|
||||||
|
@ -314,7 +425,9 @@ class xep_0030(base_plugin):
|
||||||
Otherwise the parameter is ignored.
|
Otherwise the parameter is ignored.
|
||||||
"""
|
"""
|
||||||
if local or jid is None:
|
if local or jid is None:
|
||||||
return self._run_node_handler('get_items', jid, node, kwargs)
|
items = self._run_node_handler('get_items',
|
||||||
|
jid, node, kwargs.get('ifrom', None), kwargs)
|
||||||
|
return self._wrap(kwargs.get('ifrom', None), jid, items)
|
||||||
|
|
||||||
iq = self.xmpp.Iq()
|
iq = self.xmpp.Iq()
|
||||||
# Check dfrom parameter for backwards compatibility
|
# Check dfrom parameter for backwards compatibility
|
||||||
|
@ -341,7 +454,7 @@ class xep_0030(base_plugin):
|
||||||
node -- Optional node to modify.
|
node -- Optional node to modify.
|
||||||
items -- A series of items in tuple format.
|
items -- A series of items in tuple format.
|
||||||
"""
|
"""
|
||||||
self._run_node_handler('set_items', jid, node, kwargs)
|
self._run_node_handler('set_items', jid, node, None, kwargs)
|
||||||
|
|
||||||
def del_items(self, jid=None, node=None, **kwargs):
|
def del_items(self, jid=None, node=None, **kwargs):
|
||||||
"""
|
"""
|
||||||
|
@ -351,7 +464,7 @@ class xep_0030(base_plugin):
|
||||||
jid -- The JID to modify.
|
jid -- The JID to modify.
|
||||||
node -- Optional node to modify.
|
node -- Optional node to modify.
|
||||||
"""
|
"""
|
||||||
self._run_node_handler('del_items', jid, node, kwargs)
|
self._run_node_handler('del_items', jid, node, None, kwargs)
|
||||||
|
|
||||||
def add_item(self, jid='', name='', node=None, subnode='', ijid=None):
|
def add_item(self, jid='', name='', node=None, subnode='', ijid=None):
|
||||||
"""
|
"""
|
||||||
|
@ -372,7 +485,7 @@ class xep_0030(base_plugin):
|
||||||
kwargs = {'ijid': jid,
|
kwargs = {'ijid': jid,
|
||||||
'name': name,
|
'name': name,
|
||||||
'inode': subnode}
|
'inode': subnode}
|
||||||
self._run_node_handler('add_item', ijid, node, kwargs)
|
self._run_node_handler('add_item', ijid, node, None, kwargs)
|
||||||
|
|
||||||
def del_item(self, jid=None, node=None, **kwargs):
|
def del_item(self, jid=None, node=None, **kwargs):
|
||||||
"""
|
"""
|
||||||
|
@ -384,7 +497,7 @@ class xep_0030(base_plugin):
|
||||||
ijid -- The item's JID.
|
ijid -- The item's JID.
|
||||||
inode -- The item's node.
|
inode -- The item's node.
|
||||||
"""
|
"""
|
||||||
self._run_node_handler('del_item', jid, node, kwargs)
|
self._run_node_handler('del_item', jid, node, None, kwargs)
|
||||||
|
|
||||||
def add_identity(self, category='', itype='', name='',
|
def add_identity(self, category='', itype='', name='',
|
||||||
node=None, jid=None, lang=None):
|
node=None, jid=None, lang=None):
|
||||||
|
@ -411,7 +524,7 @@ class xep_0030(base_plugin):
|
||||||
'itype': itype,
|
'itype': itype,
|
||||||
'name': name,
|
'name': name,
|
||||||
'lang': lang}
|
'lang': lang}
|
||||||
self._run_node_handler('add_identity', jid, node, kwargs)
|
self._run_node_handler('add_identity', jid, node, None, kwargs)
|
||||||
|
|
||||||
def add_feature(self, feature, node=None, jid=None):
|
def add_feature(self, feature, node=None, jid=None):
|
||||||
"""
|
"""
|
||||||
|
@ -423,7 +536,7 @@ class xep_0030(base_plugin):
|
||||||
jid -- The JID to modify.
|
jid -- The JID to modify.
|
||||||
"""
|
"""
|
||||||
kwargs = {'feature': feature}
|
kwargs = {'feature': feature}
|
||||||
self._run_node_handler('add_feature', jid, node, kwargs)
|
self._run_node_handler('add_feature', jid, node, None, kwargs)
|
||||||
|
|
||||||
def del_identity(self, jid=None, node=None, **kwargs):
|
def del_identity(self, jid=None, node=None, **kwargs):
|
||||||
"""
|
"""
|
||||||
|
@ -437,7 +550,7 @@ class xep_0030(base_plugin):
|
||||||
name -- Optional, human readable name for the identity.
|
name -- Optional, human readable name for the identity.
|
||||||
lang -- Optional, the identity's xml:lang value.
|
lang -- Optional, the identity's xml:lang value.
|
||||||
"""
|
"""
|
||||||
self._run_node_handler('del_identity', jid, node, kwargs)
|
self._run_node_handler('del_identity', jid, node, None, kwargs)
|
||||||
|
|
||||||
def del_feature(self, jid=None, node=None, **kwargs):
|
def del_feature(self, jid=None, node=None, **kwargs):
|
||||||
"""
|
"""
|
||||||
|
@ -448,7 +561,7 @@ class xep_0030(base_plugin):
|
||||||
node -- The node to modify.
|
node -- The node to modify.
|
||||||
feature -- The feature's namespace.
|
feature -- The feature's namespace.
|
||||||
"""
|
"""
|
||||||
self._run_node_handler('del_feature', jid, node, kwargs)
|
self._run_node_handler('del_feature', jid, node, None, kwargs)
|
||||||
|
|
||||||
def set_identities(self, jid=None, node=None, **kwargs):
|
def set_identities(self, jid=None, node=None, **kwargs):
|
||||||
"""
|
"""
|
||||||
|
@ -463,7 +576,7 @@ class xep_0030(base_plugin):
|
||||||
identities -- A set of identities in tuple form.
|
identities -- A set of identities in tuple form.
|
||||||
lang -- Optional, xml:lang value.
|
lang -- Optional, xml:lang value.
|
||||||
"""
|
"""
|
||||||
self._run_node_handler('set_identities', jid, node, kwargs)
|
self._run_node_handler('set_identities', jid, node, None, kwargs)
|
||||||
|
|
||||||
def del_identities(self, jid=None, node=None, **kwargs):
|
def del_identities(self, jid=None, node=None, **kwargs):
|
||||||
"""
|
"""
|
||||||
|
@ -478,7 +591,7 @@ class xep_0030(base_plugin):
|
||||||
lang -- Optional. If given, only remove identities
|
lang -- Optional. If given, only remove identities
|
||||||
using this xml:lang value.
|
using this xml:lang value.
|
||||||
"""
|
"""
|
||||||
self._run_node_handler('del_identities', jid, node, kwargs)
|
self._run_node_handler('del_identities', jid, node, None, kwargs)
|
||||||
|
|
||||||
def set_features(self, jid=None, node=None, **kwargs):
|
def set_features(self, jid=None, node=None, **kwargs):
|
||||||
"""
|
"""
|
||||||
|
@ -490,7 +603,7 @@ class xep_0030(base_plugin):
|
||||||
node -- The node to modify.
|
node -- The node to modify.
|
||||||
features -- The new set of supported features.
|
features -- The new set of supported features.
|
||||||
"""
|
"""
|
||||||
self._run_node_handler('set_features', jid, node, kwargs)
|
self._run_node_handler('set_features', jid, node, None, kwargs)
|
||||||
|
|
||||||
def del_features(self, jid=None, node=None, **kwargs):
|
def del_features(self, jid=None, node=None, **kwargs):
|
||||||
"""
|
"""
|
||||||
|
@ -500,9 +613,9 @@ class xep_0030(base_plugin):
|
||||||
jid -- The JID to modify.
|
jid -- The JID to modify.
|
||||||
node -- The node to modify.
|
node -- The node to modify.
|
||||||
"""
|
"""
|
||||||
self._run_node_handler('del_features', jid, node, kwargs)
|
self._run_node_handler('del_features', jid, node, None, kwargs)
|
||||||
|
|
||||||
def _run_node_handler(self, htype, jid, node, data={}):
|
def _run_node_handler(self, htype, jid, node=None, ifrom=None, data={}):
|
||||||
"""
|
"""
|
||||||
Execute the most specific node handler for the given
|
Execute the most specific node handler for the given
|
||||||
JID/node combination.
|
JID/node combination.
|
||||||
|
@ -513,7 +626,7 @@ class xep_0030(base_plugin):
|
||||||
node -- The node requested.
|
node -- The node requested.
|
||||||
data -- Optional, custom data to pass to the handler.
|
data -- Optional, custom data to pass to the handler.
|
||||||
"""
|
"""
|
||||||
if jid is None:
|
if jid in (None, ''):
|
||||||
if self.xmpp.is_component:
|
if self.xmpp.is_component:
|
||||||
jid = self.xmpp.boundjid.full
|
jid = self.xmpp.boundjid.full
|
||||||
else:
|
else:
|
||||||
|
@ -521,14 +634,28 @@ class xep_0030(base_plugin):
|
||||||
if node is None:
|
if node is None:
|
||||||
node = ''
|
node = ''
|
||||||
|
|
||||||
if self._handlers[htype]['node'].get((jid, node), False):
|
try:
|
||||||
return self._handlers[htype]['node'][(jid, node)](jid, node, data)
|
args = (jid, node, ifrom, data)
|
||||||
elif self._handlers[htype]['jid'].get(jid, False):
|
if self._handlers[htype]['node'].get((jid, node), False):
|
||||||
return self._handlers[htype]['jid'][jid](jid, node, data)
|
return self._handlers[htype]['node'][(jid, node)](*args)
|
||||||
elif self._handlers[htype]['global']:
|
elif self._handlers[htype]['jid'].get(jid, False):
|
||||||
return self._handlers[htype]['global'](jid, node, data)
|
return self._handlers[htype]['jid'][jid](*args)
|
||||||
else:
|
elif self._handlers[htype]['global']:
|
||||||
return None
|
return self._handlers[htype]['global'](*args)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
except TypeError:
|
||||||
|
# To preserve backward compatibility, drop the ifrom parameter
|
||||||
|
# for existing handlers that don't understand it.
|
||||||
|
args = (jid, node, data)
|
||||||
|
if self._handlers[htype]['node'].get((jid, node), False):
|
||||||
|
return self._handlers[htype]['node'][(jid, node)](*args)
|
||||||
|
elif self._handlers[htype]['jid'].get(jid, False):
|
||||||
|
return self._handlers[htype]['jid'][jid](*args)
|
||||||
|
elif self._handlers[htype]['global']:
|
||||||
|
return self._handlers[htype]['global'](*args)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
def _handle_disco_info(self, iq):
|
def _handle_disco_info(self, iq):
|
||||||
"""
|
"""
|
||||||
|
@ -550,6 +677,7 @@ class xep_0030(base_plugin):
|
||||||
info = self._run_node_handler('get_info',
|
info = self._run_node_handler('get_info',
|
||||||
jid,
|
jid,
|
||||||
iq['disco_info']['node'],
|
iq['disco_info']['node'],
|
||||||
|
iq['from'],
|
||||||
iq)
|
iq)
|
||||||
if isinstance(info, Iq):
|
if isinstance(info, Iq):
|
||||||
info.send()
|
info.send()
|
||||||
|
@ -560,8 +688,20 @@ class xep_0030(base_plugin):
|
||||||
iq.set_payload(info.xml)
|
iq.set_payload(info.xml)
|
||||||
iq.send()
|
iq.send()
|
||||||
elif iq['type'] == 'result':
|
elif iq['type'] == 'result':
|
||||||
log.debug("Received disco info result from" + \
|
log.debug("Received disco info result from " + \
|
||||||
"%s to %s.", iq['from'], iq['to'])
|
"<%s> to <%s>.", iq['from'], iq['to'])
|
||||||
|
if self.use_cache:
|
||||||
|
log.debug("Caching disco info result from " \
|
||||||
|
"<%s> to <%s>.", iq['from'], iq['to'])
|
||||||
|
if self.xmpp.is_component:
|
||||||
|
ito = iq['to'].full
|
||||||
|
else:
|
||||||
|
ito = None
|
||||||
|
self._run_node_handler('cache_info',
|
||||||
|
iq['from'].full,
|
||||||
|
iq['disco_info']['node'],
|
||||||
|
ito,
|
||||||
|
iq)
|
||||||
self.xmpp.event('disco_info', iq)
|
self.xmpp.event('disco_info', iq)
|
||||||
|
|
||||||
def _handle_disco_items(self, iq):
|
def _handle_disco_items(self, iq):
|
||||||
|
@ -583,6 +723,7 @@ class xep_0030(base_plugin):
|
||||||
items = self._run_node_handler('get_items',
|
items = self._run_node_handler('get_items',
|
||||||
jid,
|
jid,
|
||||||
iq['disco_items']['node'],
|
iq['disco_items']['node'],
|
||||||
|
iq['from'].full,
|
||||||
iq)
|
iq)
|
||||||
if isinstance(items, Iq):
|
if isinstance(items, Iq):
|
||||||
items.send()
|
items.send()
|
||||||
|
@ -592,7 +733,7 @@ class xep_0030(base_plugin):
|
||||||
iq.set_payload(items.xml)
|
iq.set_payload(items.xml)
|
||||||
iq.send()
|
iq.send()
|
||||||
elif iq['type'] == 'result':
|
elif iq['type'] == 'result':
|
||||||
log.debug("Received disco items result from" + \
|
log.debug("Received disco items result from " + \
|
||||||
"%s to %s.", iq['from'], iq['to'])
|
"%s to %s.", iq['from'], iq['to'])
|
||||||
self.xmpp.event('disco_items', iq)
|
self.xmpp.event('disco_items', iq)
|
||||||
|
|
||||||
|
@ -607,21 +748,46 @@ class xep_0030(base_plugin):
|
||||||
Arguments:
|
Arguments:
|
||||||
info -- The disco#info quest (not the full Iq stanza) to modify.
|
info -- The disco#info quest (not the full Iq stanza) to modify.
|
||||||
"""
|
"""
|
||||||
|
result = info
|
||||||
|
if isinstance(info, Iq):
|
||||||
|
info = iq['disco_info']
|
||||||
if not info['node']:
|
if not info['node']:
|
||||||
if not info['identities']:
|
if not info['identities']:
|
||||||
if self.xmpp.is_component:
|
if self.xmpp.is_component:
|
||||||
log.debug("No identity found for this entity." + \
|
log.debug("No identity found for this entity. " + \
|
||||||
"Using default component identity.")
|
"Using default component identity.")
|
||||||
info.add_identity('component', 'generic')
|
info.add_identity('component', 'generic')
|
||||||
else:
|
else:
|
||||||
log.debug("No identity found for this entity." + \
|
log.debug("No identity found for this entity. " + \
|
||||||
"Using default client identity.")
|
"Using default client identity.")
|
||||||
info.add_identity('client', 'bot')
|
info.add_identity('client', 'bot')
|
||||||
if not info['features']:
|
if not info['features']:
|
||||||
log.debug("No features found for this entity." + \
|
log.debug("No features found for this entity. " + \
|
||||||
"Using default disco#info feature.")
|
"Using default disco#info feature.")
|
||||||
info.add_feature(info.namespace)
|
info.add_feature(info.namespace)
|
||||||
return info
|
return result
|
||||||
|
|
||||||
|
def _wrap(self, ito, ifrom, payload, force=False):
|
||||||
|
"""
|
||||||
|
Ensure that results are wrapped in an Iq stanza
|
||||||
|
if self.wrap_results has been set to True.
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
ito -- The JID to use as the 'to' value
|
||||||
|
ifrom -- The JID to use as the 'from' value
|
||||||
|
payload -- The disco data to wrap
|
||||||
|
force -- Force wrapping, regardless of self.wrap_results
|
||||||
|
"""
|
||||||
|
if (force or self.wrap_results) and not isinstance(payload, Iq):
|
||||||
|
iq = self.xmpp.Iq()
|
||||||
|
# Since we're simulating a result, we have to treat
|
||||||
|
# the 'from' and 'to' values opposite the normal way.
|
||||||
|
iq['to'] = self.xmpp.boundjid if ito is None else ito
|
||||||
|
iq['from'] = self.xmpp.boundjid if ifrom is None else ifrom
|
||||||
|
iq['type'] = 'result'
|
||||||
|
iq.append(payload)
|
||||||
|
return iq
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
# Retain some backwards compatibility
|
# Retain some backwards compatibility
|
||||||
|
|
|
@ -146,7 +146,7 @@ class DiscoInfo(ElementBase):
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def get_identities(self, lang=None):
|
def get_identities(self, lang=None, dedupe=True):
|
||||||
"""
|
"""
|
||||||
Return a set of all identities in tuple form as so:
|
Return a set of all identities in tuple form as so:
|
||||||
(category, type, lang, name)
|
(category, type, lang, name)
|
||||||
|
@ -155,17 +155,25 @@ class DiscoInfo(ElementBase):
|
||||||
that language.
|
that language.
|
||||||
|
|
||||||
Arguments:
|
Arguments:
|
||||||
lang -- Optional, standard xml:lang value.
|
lang -- Optional, standard xml:lang value.
|
||||||
|
dedupe -- If True, de-duplicate identities, otherwise
|
||||||
|
return a list of all identities.
|
||||||
"""
|
"""
|
||||||
identities = set()
|
if dedupe:
|
||||||
|
identities = set()
|
||||||
|
else:
|
||||||
|
identities = []
|
||||||
for id_xml in self.findall('{%s}identity' % self.namespace):
|
for id_xml in self.findall('{%s}identity' % self.namespace):
|
||||||
xml_lang = id_xml.attrib.get('{%s}lang' % self.xml_ns, None)
|
xml_lang = id_xml.attrib.get('{%s}lang' % self.xml_ns, None)
|
||||||
if lang is None or xml_lang == lang:
|
if lang is None or xml_lang == lang:
|
||||||
identities.add((
|
id = (id_xml.attrib['category'],
|
||||||
id_xml.attrib['category'],
|
id_xml.attrib['type'],
|
||||||
id_xml.attrib['type'],
|
id_xml.attrib.get('{%s}lang' % self.xml_ns, None),
|
||||||
id_xml.attrib.get('{%s}lang' % self.xml_ns, None),
|
id_xml.attrib.get('name', None))
|
||||||
id_xml.attrib.get('name', None)))
|
if dedupe:
|
||||||
|
identities.add(id)
|
||||||
|
else:
|
||||||
|
identities.append(id)
|
||||||
return identities
|
return identities
|
||||||
|
|
||||||
def set_identities(self, identities, lang=None):
|
def set_identities(self, identities, lang=None):
|
||||||
|
@ -237,11 +245,17 @@ class DiscoInfo(ElementBase):
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def get_features(self):
|
def get_features(self, dedupe=True):
|
||||||
"""Return the set of all supported features."""
|
"""Return the set of all supported features."""
|
||||||
features = set()
|
if dedupe:
|
||||||
|
features = set()
|
||||||
|
else:
|
||||||
|
features = []
|
||||||
for feature_xml in self.findall('{%s}feature' % self.namespace):
|
for feature_xml in self.findall('{%s}feature' % self.namespace):
|
||||||
features.add(feature_xml.attrib['var'])
|
if dedupe:
|
||||||
|
features.add(feature_xml.attrib['var'])
|
||||||
|
else:
|
||||||
|
features.append(feature_xml.attrib['var'])
|
||||||
return features
|
return features
|
||||||
|
|
||||||
def set_features(self, features):
|
def set_features(self, features):
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import threading
|
||||||
|
|
||||||
import sleekxmpp
|
import sleekxmpp
|
||||||
from sleekxmpp import Iq
|
from sleekxmpp import Iq
|
||||||
|
@ -50,8 +51,10 @@ class StaticDisco(object):
|
||||||
"""
|
"""
|
||||||
self.nodes = {}
|
self.nodes = {}
|
||||||
self.xmpp = xmpp
|
self.xmpp = xmpp
|
||||||
|
self.disco = xmpp['xep_0030']
|
||||||
|
self.lock = threading.RLock()
|
||||||
|
|
||||||
def add_node(self, jid=None, node=None):
|
def add_node(self, jid=None, node=None, ifrom=None):
|
||||||
"""
|
"""
|
||||||
Create a new set of stanzas for the provided
|
Create a new set of stanzas for the provided
|
||||||
JID and node combination.
|
JID and node combination.
|
||||||
|
@ -60,83 +63,219 @@ class StaticDisco(object):
|
||||||
jid -- The JID that will own the new stanzas.
|
jid -- The JID that will own the new stanzas.
|
||||||
node -- The node that will own the new stanzas.
|
node -- The node that will own the new stanzas.
|
||||||
"""
|
"""
|
||||||
if jid is None:
|
with self.lock:
|
||||||
jid = self.xmpp.boundjid.full
|
if jid is None:
|
||||||
if node is None:
|
jid = self.xmpp.boundjid.full
|
||||||
node = ''
|
if node is None:
|
||||||
if (jid, node) not in self.nodes:
|
node = ''
|
||||||
self.nodes[(jid, node)] = {'info': DiscoInfo(),
|
if ifrom is None:
|
||||||
'items': DiscoItems()}
|
ifrom = ''
|
||||||
self.nodes[(jid, node)]['info']['node'] = node
|
if isinstance(ifrom, JID):
|
||||||
self.nodes[(jid, node)]['items']['node'] = node
|
ifrom = ifrom.full
|
||||||
|
if (jid, node, ifrom) not in self.nodes:
|
||||||
|
self.nodes[(jid, node, ifrom)] = {'info': DiscoInfo(),
|
||||||
|
'items': DiscoItems()}
|
||||||
|
self.nodes[(jid, node, ifrom)]['info']['node'] = node
|
||||||
|
self.nodes[(jid, node, ifrom)]['items']['node'] = node
|
||||||
|
|
||||||
|
def get_node(self, jid=None, node=None, ifrom=None):
|
||||||
|
with self.lock:
|
||||||
|
if jid is None:
|
||||||
|
jid = self.xmpp.boundjid.full
|
||||||
|
if node is None:
|
||||||
|
node = ''
|
||||||
|
if ifrom is None:
|
||||||
|
ifrom = ''
|
||||||
|
if isinstance(ifrom, JID):
|
||||||
|
ifrom = ifrom.full
|
||||||
|
if (jid, node, ifrom) not in self.nodes:
|
||||||
|
self.add_node(jid, node, ifrom)
|
||||||
|
return self.nodes[(jid, node, ifrom)]
|
||||||
|
|
||||||
|
def node_exists(self, jid=None, node=None, ifrom=None):
|
||||||
|
with self.lock:
|
||||||
|
if jid is None:
|
||||||
|
jid = self.xmpp.boundjid.full
|
||||||
|
if node is None:
|
||||||
|
node = ''
|
||||||
|
if ifrom is None:
|
||||||
|
ifrom = ''
|
||||||
|
if isinstance(ifrom, JID):
|
||||||
|
ifrom = ifrom.full
|
||||||
|
if (jid, node, ifrom) not in self.nodes:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
# =================================================================
|
# =================================================================
|
||||||
# Node Handlers
|
# Node Handlers
|
||||||
#
|
#
|
||||||
# Each handler accepts three arguments: jid, node, and data.
|
# Each handler accepts four arguments: jid, node, ifrom, and data.
|
||||||
# The jid and node parameters together determine the set of
|
# The jid and node parameters together determine the set of info
|
||||||
# info and items stanzas that will be retrieved or added.
|
# and items stanzas that will be retrieved or added. Additionally,
|
||||||
# The data parameter is a dictionary with additional paramters
|
# the ifrom value allows for cached results when results vary based
|
||||||
# that will be passed to other calls.
|
# on the requester's JID. The data parameter is a dictionary with
|
||||||
|
# additional parameters that will be passed to other calls.
|
||||||
|
#
|
||||||
|
# This implementation does not allow different responses based on
|
||||||
|
# the requester's JID, except for cached results. To do that,
|
||||||
|
# register a custom node handler.
|
||||||
|
|
||||||
def get_info(self, jid, node, data):
|
def supports(self, jid, node, ifrom, data):
|
||||||
|
"""
|
||||||
|
Check if a JID supports a given feature.
|
||||||
|
|
||||||
|
The data parameter may provide:
|
||||||
|
feature -- The feature to check for support.
|
||||||
|
local -- If true, then the query is for a JID/node
|
||||||
|
combination handled by this Sleek instance and
|
||||||
|
no stanzas need to be sent.
|
||||||
|
Otherwise, a disco stanza must be sent to the
|
||||||
|
remove JID to retrieve the info.
|
||||||
|
cached -- If true, then look for the disco info data from
|
||||||
|
the local cache system. If no results are found,
|
||||||
|
send the query as usual. The self.use_cache
|
||||||
|
setting must be set to true for this option to
|
||||||
|
be useful. If set to false, then the cache will
|
||||||
|
be skipped, even if a result has already been
|
||||||
|
cached. Defaults to false.
|
||||||
|
"""
|
||||||
|
feature = data.get('feature', None)
|
||||||
|
|
||||||
|
data = {'local': data.get('local', False),
|
||||||
|
'cached': data.get('cached', True)}
|
||||||
|
|
||||||
|
if not feature:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
info = self.disco.get_info(jid=jid, node=node,
|
||||||
|
ifrom=ifrom, **data)
|
||||||
|
info = self.disco._wrap(ifrom, jid, info, True)
|
||||||
|
features = info['disco_info']['features']
|
||||||
|
return feature in features
|
||||||
|
except IqError:
|
||||||
|
return False
|
||||||
|
except IqTimeout:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def has_identity(self, jid, node, ifrom, data):
|
||||||
|
"""
|
||||||
|
Check if a JID has a given identity.
|
||||||
|
|
||||||
|
The data parameter may provide:
|
||||||
|
category -- The category of the identity to check.
|
||||||
|
itype -- The type of the identity to check.
|
||||||
|
lang -- The language of the identity to check.
|
||||||
|
local -- If true, then the query is for a JID/node
|
||||||
|
combination handled by this Sleek instance and
|
||||||
|
no stanzas need to be sent.
|
||||||
|
Otherwise, a disco stanza must be sent to the
|
||||||
|
remove JID to retrieve the info.
|
||||||
|
cached -- If true, then look for the disco info data from
|
||||||
|
the local cache system. If no results are found,
|
||||||
|
send the query as usual. The self.use_cache
|
||||||
|
setting must be set to true for this option to
|
||||||
|
be useful. If set to false, then the cache will
|
||||||
|
be skipped, even if a result has already been
|
||||||
|
cached. Defaults to false.
|
||||||
|
"""
|
||||||
|
identity = (data.get('category', None),
|
||||||
|
data.get('itype', None),
|
||||||
|
data.get('lang', None))
|
||||||
|
|
||||||
|
data = {'local': data.get('local', False),
|
||||||
|
'cached': data.get('cached', True)}
|
||||||
|
|
||||||
|
if node in (None, ''):
|
||||||
|
info = self.caps.get_caps(jid)
|
||||||
|
if info and identity in info['identities']:
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
info = self.disco.get_info(jid=jid, node=node,
|
||||||
|
ifrom=ifrom, **data)
|
||||||
|
info = self.disco._wrap(ifrom, jid, info, True)
|
||||||
|
trunc = lambda i: (i[0], i[1], i[2])
|
||||||
|
return identity in map(trunc, info['disco_info']['identities'])
|
||||||
|
except IqError:
|
||||||
|
return False
|
||||||
|
except IqTimeout:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_info(self, jid, node, ifrom, data):
|
||||||
"""
|
"""
|
||||||
Return the stored info data for the requested JID/node combination.
|
Return the stored info data for the requested JID/node combination.
|
||||||
|
|
||||||
The data parameter is not used.
|
The data parameter is not used.
|
||||||
"""
|
"""
|
||||||
if (jid, node) not in self.nodes:
|
with self.lock:
|
||||||
if not node:
|
if not self.node_exists(jid, node):
|
||||||
return DiscoInfo()
|
if not node:
|
||||||
|
return DiscoInfo()
|
||||||
|
else:
|
||||||
|
raise XMPPError(condition='item-not-found')
|
||||||
else:
|
else:
|
||||||
raise XMPPError(condition='item-not-found')
|
return self.get_node(jid, node)['info']
|
||||||
else:
|
|
||||||
return self.nodes[(jid, node)]['info']
|
|
||||||
|
|
||||||
def del_info(self, jid, node, data):
|
def set_info(self, jid, node, ifrom, data):
|
||||||
|
"""
|
||||||
|
Set the entire info stanza for a JID/node at once.
|
||||||
|
|
||||||
|
The data parameter is a disco#info substanza.
|
||||||
|
"""
|
||||||
|
with self.lock:
|
||||||
|
self.add_node(jid, node)
|
||||||
|
self.get_node(jid, node)['info'] = data
|
||||||
|
|
||||||
|
def del_info(self, jid, node, ifrom, data):
|
||||||
"""
|
"""
|
||||||
Reset the info stanza for a given JID/node combination.
|
Reset the info stanza for a given JID/node combination.
|
||||||
|
|
||||||
The data parameter is not used.
|
The data parameter is not used.
|
||||||
"""
|
"""
|
||||||
if (jid, node) in self.nodes:
|
with self.lock:
|
||||||
self.nodes[(jid, node)]['info'] = DiscoInfo()
|
if self.node_exists(jid, node):
|
||||||
|
self.get_node(jid, node)['info'] = DiscoInfo()
|
||||||
|
|
||||||
def get_items(self, jid, node, data):
|
def get_items(self, jid, node, ifrom, data):
|
||||||
"""
|
"""
|
||||||
Return the stored items data for the requested JID/node combination.
|
Return the stored items data for the requested JID/node combination.
|
||||||
|
|
||||||
The data parameter is not used.
|
The data parameter is not used.
|
||||||
"""
|
"""
|
||||||
if (jid, node) not in self.nodes:
|
with self.lock:
|
||||||
if not node:
|
if not self.node_exists(jid, node):
|
||||||
return DiscoInfo()
|
if not node:
|
||||||
|
return DiscoInfo()
|
||||||
|
else:
|
||||||
|
raise XMPPError(condition='item-not-found')
|
||||||
else:
|
else:
|
||||||
raise XMPPError(condition='item-not-found')
|
return self.get_node(jid, node)['items']
|
||||||
else:
|
|
||||||
return self.nodes[(jid, node)]['items']
|
|
||||||
|
|
||||||
def set_items(self, jid, node, data):
|
def set_items(self, jid, node, ifrom, data):
|
||||||
"""
|
"""
|
||||||
Replace the stored items data for a JID/node combination.
|
Replace the stored items data for a JID/node combination.
|
||||||
|
|
||||||
The data parameter may provided:
|
The data parameter may provide:
|
||||||
items -- A set of items in tuple format.
|
items -- A set of items in tuple format.
|
||||||
"""
|
"""
|
||||||
items = data.get('items', set())
|
with self.lock:
|
||||||
self.add_node(jid, node)
|
items = data.get('items', set())
|
||||||
self.nodes[(jid, node)]['items']['items'] = items
|
self.add_node(jid, node)
|
||||||
|
self.get_node(jid, node)['items']['items'] = items
|
||||||
|
|
||||||
def del_items(self, jid, node, data):
|
def del_items(self, jid, node, ifrom, data):
|
||||||
"""
|
"""
|
||||||
Reset the items stanza for a given JID/node combination.
|
Reset the items stanza for a given JID/node combination.
|
||||||
|
|
||||||
The data parameter is not used.
|
The data parameter is not used.
|
||||||
"""
|
"""
|
||||||
if (jid, node) in self.nodes:
|
with self.lock:
|
||||||
self.nodes[(jid, node)]['items'] = DiscoItems()
|
if self.node_exists(jid, node):
|
||||||
|
self.get_node(jid, node)['items'] = DiscoItems()
|
||||||
|
|
||||||
def add_identity(self, jid, node, data):
|
def add_identity(self, jid, node, ifrom, data):
|
||||||
"""
|
"""
|
||||||
Add a new identity to te JID/node combination.
|
Add a new identity to te JID/node combination.
|
||||||
|
|
||||||
|
@ -146,14 +285,15 @@ class StaticDisco(object):
|
||||||
name -- Optional human readable name for this identity.
|
name -- Optional human readable name for this identity.
|
||||||
lang -- Optional standard xml:lang value.
|
lang -- Optional standard xml:lang value.
|
||||||
"""
|
"""
|
||||||
self.add_node(jid, node)
|
with self.lock:
|
||||||
self.nodes[(jid, node)]['info'].add_identity(
|
self.add_node(jid, node)
|
||||||
data.get('category', ''),
|
self.get_node(jid, node)['info'].add_identity(
|
||||||
data.get('itype', ''),
|
data.get('category', ''),
|
||||||
data.get('name', None),
|
data.get('itype', ''),
|
||||||
data.get('lang', None))
|
data.get('name', None),
|
||||||
|
data.get('lang', None))
|
||||||
|
|
||||||
def set_identities(self, jid, node, data):
|
def set_identities(self, jid, node, ifrom, data):
|
||||||
"""
|
"""
|
||||||
Add or replace all identities for a JID/node combination.
|
Add or replace all identities for a JID/node combination.
|
||||||
|
|
||||||
|
@ -161,11 +301,12 @@ class StaticDisco(object):
|
||||||
identities -- A list of identities in tuple form:
|
identities -- A list of identities in tuple form:
|
||||||
(category, type, name, lang)
|
(category, type, name, lang)
|
||||||
"""
|
"""
|
||||||
identities = data.get('identities', set())
|
with self.lock:
|
||||||
self.add_node(jid, node)
|
identities = data.get('identities', set())
|
||||||
self.nodes[(jid, node)]['info']['identities'] = identities
|
self.add_node(jid, node)
|
||||||
|
self.get_node(jid, node)['info']['identities'] = identities
|
||||||
|
|
||||||
def del_identity(self, jid, node, data):
|
def del_identity(self, jid, node, ifrom, data):
|
||||||
"""
|
"""
|
||||||
Remove an identity from a JID/node combination.
|
Remove an identity from a JID/node combination.
|
||||||
|
|
||||||
|
@ -175,67 +316,70 @@ class StaticDisco(object):
|
||||||
name -- Optional human readable name for this identity.
|
name -- Optional human readable name for this identity.
|
||||||
lang -- Optional, standard xml:lang value.
|
lang -- Optional, standard xml:lang value.
|
||||||
"""
|
"""
|
||||||
if (jid, node) not in self.nodes:
|
with self.lock:
|
||||||
return
|
if self.node_exists(jid, node):
|
||||||
self.nodes[(jid, node)]['info'].del_identity(
|
self.get_node(jid, node)['info'].del_identity(
|
||||||
data.get('category', ''),
|
data.get('category', ''),
|
||||||
data.get('itype', ''),
|
data.get('itype', ''),
|
||||||
data.get('name', None),
|
data.get('name', None),
|
||||||
data.get('lang', None))
|
data.get('lang', None))
|
||||||
|
|
||||||
def del_identities(self, jid, node, data):
|
def del_identities(self, jid, node, ifrom, data):
|
||||||
"""
|
"""
|
||||||
Remove all identities from a JID/node combination.
|
Remove all identities from a JID/node combination.
|
||||||
|
|
||||||
The data parameter is not used.
|
The data parameter is not used.
|
||||||
"""
|
"""
|
||||||
if (jid, node) not in self.nodes:
|
with self.lock:
|
||||||
return
|
if self.node_exists(jid, node):
|
||||||
del self.nodes[(jid, node)]['info']['identities']
|
del self.get_node(jid, node)['info']['identities']
|
||||||
|
|
||||||
def add_feature(self, jid, node, data):
|
def add_feature(self, jid, node, ifrom, data):
|
||||||
"""
|
"""
|
||||||
Add a feature to a JID/node combination.
|
Add a feature to a JID/node combination.
|
||||||
|
|
||||||
The data parameter should include:
|
The data parameter should include:
|
||||||
feature -- The namespace of the supported feature.
|
feature -- The namespace of the supported feature.
|
||||||
"""
|
"""
|
||||||
self.add_node(jid, node)
|
with self.lock:
|
||||||
self.nodes[(jid, node)]['info'].add_feature(data.get('feature', ''))
|
self.add_node(jid, node)
|
||||||
|
self.get_node(jid, node)['info'].add_feature(data.get('feature', ''))
|
||||||
|
|
||||||
def set_features(self, jid, node, data):
|
def set_features(self, jid, node, ifrom, data):
|
||||||
"""
|
"""
|
||||||
Add or replace all features for a JID/node combination.
|
Add or replace all features for a JID/node combination.
|
||||||
|
|
||||||
The data parameter should include:
|
The data parameter should include:
|
||||||
features -- The new set of supported features.
|
features -- The new set of supported features.
|
||||||
"""
|
"""
|
||||||
features = data.get('features', set())
|
with self.lock:
|
||||||
self.add_node(jid, node)
|
features = data.get('features', set())
|
||||||
self.nodes[(jid, node)]['info']['features'] = features
|
self.add_node(jid, node)
|
||||||
|
self.get_node(jid, node)['info']['features'] = features
|
||||||
|
|
||||||
def del_feature(self, jid, node, data):
|
def del_feature(self, jid, node, ifrom, data):
|
||||||
"""
|
"""
|
||||||
Remove a feature from a JID/node combination.
|
Remove a feature from a JID/node combination.
|
||||||
|
|
||||||
The data parameter should include:
|
The data parameter should include:
|
||||||
feature -- The namespace of the removed feature.
|
feature -- The namespace of the removed feature.
|
||||||
"""
|
"""
|
||||||
if (jid, node) not in self.nodes:
|
with self.lock:
|
||||||
return
|
if self.node_exists(jid, node):
|
||||||
self.nodes[(jid, node)]['info'].del_feature(data.get('feature', ''))
|
self.get_node(jid, node)['info'].del_feature(data.get('feature', ''))
|
||||||
|
|
||||||
def del_features(self, jid, node, data):
|
def del_features(self, jid, node, ifrom, data):
|
||||||
"""
|
"""
|
||||||
Remove all features from a JID/node combination.
|
Remove all features from a JID/node combination.
|
||||||
|
|
||||||
The data parameter is not used.
|
The data parameter is not used.
|
||||||
"""
|
"""
|
||||||
if (jid, node) not in self.nodes:
|
with self.lock:
|
||||||
return
|
if not self.node_exists(jid, node):
|
||||||
del self.nodes[(jid, node)]['info']['features']
|
return
|
||||||
|
del self.get_node(jid, node)['info']['features']
|
||||||
|
|
||||||
def add_item(self, jid, node, data):
|
def add_item(self, jid, node, ifrom, data):
|
||||||
"""
|
"""
|
||||||
Add an item to a JID/node combination.
|
Add an item to a JID/node combination.
|
||||||
|
|
||||||
|
@ -245,13 +389,14 @@ class StaticDisco(object):
|
||||||
non-addressable items.
|
non-addressable items.
|
||||||
name -- Optional human readable name for the item.
|
name -- Optional human readable name for the item.
|
||||||
"""
|
"""
|
||||||
self.add_node(jid, node)
|
with self.lock:
|
||||||
self.nodes[(jid, node)]['items'].add_item(
|
self.add_node(jid, node)
|
||||||
data.get('ijid', ''),
|
self.get_node(jid, node)['items'].add_item(
|
||||||
node=data.get('inode', ''),
|
data.get('ijid', ''),
|
||||||
name=data.get('name', ''))
|
node=data.get('inode', ''),
|
||||||
|
name=data.get('name', ''))
|
||||||
|
|
||||||
def del_item(self, jid, node, data):
|
def del_item(self, jid, node, ifrom, data):
|
||||||
"""
|
"""
|
||||||
Remove an item from a JID/node combination.
|
Remove an item from a JID/node combination.
|
||||||
|
|
||||||
|
@ -259,7 +404,38 @@ class StaticDisco(object):
|
||||||
ijid -- JID of the item to remove.
|
ijid -- JID of the item to remove.
|
||||||
inode -- Optional extra identifying information.
|
inode -- Optional extra identifying information.
|
||||||
"""
|
"""
|
||||||
if (jid, node) in self.nodes:
|
with self.lock:
|
||||||
self.nodes[(jid, node)]['items'].del_item(
|
if self.node_exists(jid, node):
|
||||||
data.get('ijid', ''),
|
self.get_node(jid, node)['items'].del_item(
|
||||||
node=data.get('inode', None))
|
data.get('ijid', ''),
|
||||||
|
node=data.get('inode', None))
|
||||||
|
|
||||||
|
def cache_info(self, jid, node, ifrom, data):
|
||||||
|
"""
|
||||||
|
Cache disco information for an external JID.
|
||||||
|
|
||||||
|
The data parameter is the Iq result stanza
|
||||||
|
containing the disco info to cache, or
|
||||||
|
the disco#info substanza itself.
|
||||||
|
"""
|
||||||
|
with self.lock:
|
||||||
|
if isinstance(data, Iq):
|
||||||
|
data = data['disco_info']
|
||||||
|
|
||||||
|
self.add_node(jid, node, ifrom)
|
||||||
|
self.get_node(jid, node, ifrom)['info'] = data
|
||||||
|
|
||||||
|
def get_cached_info(self, jid, node, ifrom, data):
|
||||||
|
"""
|
||||||
|
Retrieve cached disco info data.
|
||||||
|
|
||||||
|
The data parameter is not used.
|
||||||
|
"""
|
||||||
|
with self.lock:
|
||||||
|
if isinstance(jid, JID):
|
||||||
|
jid = jid.full
|
||||||
|
|
||||||
|
if not self.node_exists(jid, node, ifrom):
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
return self.get_node(jid, node, ifrom)['info']
|
||||||
|
|
11
sleekxmpp/plugins/xep_0115/__init__.py
Normal file
11
sleekxmpp/plugins/xep_0115/__init__.py
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
"""
|
||||||
|
SleekXMPP: The Sleek XMPP Library
|
||||||
|
Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout
|
||||||
|
This file is part of SleekXMPP.
|
||||||
|
|
||||||
|
See the file LICENSE for copying permission.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sleekxmpp.plugins.xep_0115.stanza import Capabilities
|
||||||
|
from sleekxmpp.plugins.xep_0115.static import StaticCaps
|
||||||
|
from sleekxmpp.plugins.xep_0115.caps import xep_0115
|
290
sleekxmpp/plugins/xep_0115/caps.py
Normal file
290
sleekxmpp/plugins/xep_0115/caps.py
Normal file
|
@ -0,0 +1,290 @@
|
||||||
|
"""
|
||||||
|
SleekXMPP: The Sleek XMPP Library
|
||||||
|
Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout
|
||||||
|
This file is part of SleekXMPP.
|
||||||
|
|
||||||
|
See the file LICENSE for copying permission.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import hashlib
|
||||||
|
import base64
|
||||||
|
|
||||||
|
import sleekxmpp
|
||||||
|
from sleekxmpp.stanza import StreamFeatures, Presence, Iq
|
||||||
|
from sleekxmpp.xmlstream import register_stanza_plugin, JID
|
||||||
|
from sleekxmpp.xmlstream.handler import Callback
|
||||||
|
from sleekxmpp.xmlstream.matcher import StanzaPath
|
||||||
|
from sleekxmpp.exceptions import XMPPError, IqError, IqTimeout
|
||||||
|
from sleekxmpp.plugins.base import base_plugin
|
||||||
|
from sleekxmpp.plugins.xep_0115 import stanza, StaticCaps
|
||||||
|
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class xep_0115(base_plugin):
|
||||||
|
|
||||||
|
"""
|
||||||
|
XEP-0115: Entity Capabalities
|
||||||
|
"""
|
||||||
|
|
||||||
|
def plugin_init(self):
|
||||||
|
self.xep = '0115'
|
||||||
|
self.description = 'Entity Capabilities'
|
||||||
|
self.stanza = stanza
|
||||||
|
|
||||||
|
self.hashes = {'sha-1': hashlib.sha1,
|
||||||
|
'md5': hashlib.md5}
|
||||||
|
|
||||||
|
self.hash = self.config.get('hash', 'sha-1')
|
||||||
|
self.caps_node = self.config.get('caps_node', None)
|
||||||
|
self.broadcast = self.config.get('broadcast', True)
|
||||||
|
|
||||||
|
if self.caps_node is None:
|
||||||
|
ver = sleekxmpp.__version__
|
||||||
|
self.caps_node = 'http://sleekxmpp.com/ver/%s' % ver
|
||||||
|
|
||||||
|
register_stanza_plugin(Presence, stanza.Capabilities)
|
||||||
|
register_stanza_plugin(StreamFeatures, stanza.Capabilities)
|
||||||
|
|
||||||
|
self._disco_ops = ['cache_caps',
|
||||||
|
'get_caps',
|
||||||
|
'assign_verstring',
|
||||||
|
'get_verstring',
|
||||||
|
'supports',
|
||||||
|
'has_identity']
|
||||||
|
|
||||||
|
self.xmpp.register_handler(
|
||||||
|
Callback('Entity Capabilites',
|
||||||
|
StanzaPath('presence/caps'),
|
||||||
|
self._handle_caps))
|
||||||
|
|
||||||
|
self.xmpp.add_filter('out', self._filter_add_caps)
|
||||||
|
|
||||||
|
self.xmpp.add_event_handler('entity_caps', self._process_caps,
|
||||||
|
threaded=True)
|
||||||
|
|
||||||
|
self.xmpp.register_feature('caps',
|
||||||
|
self._handle_caps_feature,
|
||||||
|
restart=False,
|
||||||
|
order=10010)
|
||||||
|
|
||||||
|
def post_init(self):
|
||||||
|
base_plugin.post_init(self)
|
||||||
|
self.xmpp['xep_0030'].add_feature(stanza.Capabilities.namespace)
|
||||||
|
|
||||||
|
disco = self.xmpp['xep_0030']
|
||||||
|
self.static = StaticCaps(self.xmpp, disco.static)
|
||||||
|
|
||||||
|
for op in self._disco_ops:
|
||||||
|
disco._add_disco_op(op, getattr(self.static, op))
|
||||||
|
|
||||||
|
self._run_node_handler = disco._run_node_handler
|
||||||
|
|
||||||
|
disco.cache_caps = self.cache_caps
|
||||||
|
disco.update_caps = self.update_caps
|
||||||
|
disco.assign_verstring = self.assign_verstring
|
||||||
|
disco.get_verstring = self.get_verstring
|
||||||
|
|
||||||
|
def _filter_add_caps(self, stanza):
|
||||||
|
if isinstance(stanza, Presence) and self.broadcast:
|
||||||
|
ver = self.get_verstring(stanza['from'])
|
||||||
|
if ver:
|
||||||
|
stanza['caps']['node'] = self.caps_node
|
||||||
|
stanza['caps']['hash'] = self.hash
|
||||||
|
stanza['caps']['ver'] = ver
|
||||||
|
return stanza
|
||||||
|
|
||||||
|
def _handle_caps(self, presence):
|
||||||
|
if not self.xmpp.is_component:
|
||||||
|
if presence['from'] == self.xmpp.boundjid:
|
||||||
|
return
|
||||||
|
self.xmpp.event('entity_caps', presence)
|
||||||
|
|
||||||
|
def _handle_caps_feature(self, features):
|
||||||
|
# We already have a method to process presence with
|
||||||
|
# caps, so wrap things up and use that.
|
||||||
|
p = Presence()
|
||||||
|
p['from'] = self.xmpp.boundjid.domain
|
||||||
|
p.append(features['caps'])
|
||||||
|
self.xmpp.features.add('caps')
|
||||||
|
|
||||||
|
self.xmpp.event('entity_caps', p)
|
||||||
|
|
||||||
|
def _process_caps(self, pres):
|
||||||
|
if not pres['caps']['hash']:
|
||||||
|
log.debug("Received unsupported legacy caps.")
|
||||||
|
self.xmpp.event('entity_caps_legacy', pres)
|
||||||
|
return
|
||||||
|
|
||||||
|
existing_verstring = self.get_verstring(pres['from'].full)
|
||||||
|
if str(existing_verstring) == str(pres['caps']['ver']):
|
||||||
|
return
|
||||||
|
|
||||||
|
if pres['caps']['hash'] not in self.hashes:
|
||||||
|
try:
|
||||||
|
log.debug("Unknown caps hash: %s", pres['caps']['hash'])
|
||||||
|
self.xmpp['xep_003'].get_info(jid=pres['from'].full)
|
||||||
|
return
|
||||||
|
except XMPPError:
|
||||||
|
return
|
||||||
|
|
||||||
|
log.debug("New caps verification string: %s", pres['caps']['ver'])
|
||||||
|
try:
|
||||||
|
caps = self.xmpp['xep_0030'].get_info(
|
||||||
|
jid=pres['from'].full,
|
||||||
|
node='%s#%s' % (pres['caps']['node'],
|
||||||
|
pres['caps']['ver']))
|
||||||
|
|
||||||
|
if self._validate_caps(caps['disco_info'],
|
||||||
|
pres['caps']['hash'],
|
||||||
|
pres['caps']['ver']):
|
||||||
|
self.assign_verstring(pres['from'], pres['caps']['ver'])
|
||||||
|
except XMPPError:
|
||||||
|
log.debug("Could not retrieve disco#info results for caps")
|
||||||
|
|
||||||
|
def _validate_caps(self, caps, hash, check_verstring):
|
||||||
|
# Check Identities
|
||||||
|
full_ids = caps.get_identities(dedupe=False)
|
||||||
|
deduped_ids = caps.get_identities()
|
||||||
|
if len(full_ids) != len(deduped_ids):
|
||||||
|
log.debug("Duplicate disco identities found, invalid for caps")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check Features
|
||||||
|
|
||||||
|
full_features = caps.get_features(dedupe=False)
|
||||||
|
deduped_features = caps.get_features()
|
||||||
|
if len(full_features) != len(deduped_features):
|
||||||
|
log.debug("Duplicate disco features found, invalid for caps")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check Forms
|
||||||
|
form_types = []
|
||||||
|
deduped_form_types = set()
|
||||||
|
for stanza in caps['substanzas']:
|
||||||
|
if isinstance(stanza, self.xmpp['xep_0004'].stanza.Form):
|
||||||
|
if 'FORM_TYPE' in stanza['fields']:
|
||||||
|
f_type = tuple(stanza['fields']['FORM_TYPE']['value'])
|
||||||
|
form_types.append(f_type)
|
||||||
|
deduped_form_types.add(f_type)
|
||||||
|
if len(form_types) != len(deduped_form_types):
|
||||||
|
log.debug("Duplicated FORM_TYPE values, invalid for caps")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if len(f_type) > 1:
|
||||||
|
deduped_type = set(f_type)
|
||||||
|
if len(f_type) != len(deduped_type):
|
||||||
|
log.debug("Extra FORM_TYPE data, invalid for caps")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if stanza['fields']['FORM_TYPE']['type'] != 'hidden':
|
||||||
|
log.debug("Field FORM_TYPE type not 'hidden', ignoring form for caps")
|
||||||
|
caps.xml.remove(stanza.xml)
|
||||||
|
else:
|
||||||
|
log.debug("No FORM_TYPE found, ignoring form for caps")
|
||||||
|
caps.xml.remove(stanza.xml)
|
||||||
|
|
||||||
|
verstring = self.generate_verstring(caps, hash)
|
||||||
|
if verstring != check_verstring:
|
||||||
|
log.debug("Verification strings do not match: %s, %s" % (
|
||||||
|
verstring, check_verstring))
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.cache_caps(verstring, caps)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def generate_verstring(self, info, hash):
|
||||||
|
hash = self.hashes.get(hash, None)
|
||||||
|
if hash is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
S = ''
|
||||||
|
|
||||||
|
# Convert None to '' in the identities
|
||||||
|
def clean_identity(id):
|
||||||
|
return map(lambda i: i or '', id)
|
||||||
|
identities = map(clean_identity, info['identities'])
|
||||||
|
|
||||||
|
identities = sorted(('/'.join(i) for i in identities))
|
||||||
|
features = sorted(info['features'])
|
||||||
|
|
||||||
|
S += '<'.join(identities) + '<'
|
||||||
|
S += '<'.join(features) + '<'
|
||||||
|
|
||||||
|
form_types = {}
|
||||||
|
|
||||||
|
for stanza in info['substanzas']:
|
||||||
|
if isinstance(stanza, self.xmpp['xep_0004'].stanza.Form):
|
||||||
|
if 'FORM_TYPE' in stanza['fields']:
|
||||||
|
f_type = stanza['values']['FORM_TYPE']
|
||||||
|
if len(f_type):
|
||||||
|
f_type = f_type[0]
|
||||||
|
if f_type not in form_types:
|
||||||
|
form_types[f_type] = []
|
||||||
|
form_types[f_type].append(stanza)
|
||||||
|
|
||||||
|
sorted_forms = sorted(form_types.keys())
|
||||||
|
for f_type in sorted_forms:
|
||||||
|
for form in form_types[f_type]:
|
||||||
|
S += '%s<' % f_type
|
||||||
|
fields = sorted(form['fields'].keys())
|
||||||
|
fields.remove('FORM_TYPE')
|
||||||
|
for field in fields:
|
||||||
|
S += '%s<' % field
|
||||||
|
vals = form['fields'][field].get_value(convert=False)
|
||||||
|
if vals is None:
|
||||||
|
S += '<'
|
||||||
|
else:
|
||||||
|
if not isinstance(vals, list):
|
||||||
|
vals = [vals]
|
||||||
|
S += '<'.join(sorted(vals)) + '<'
|
||||||
|
|
||||||
|
binary = hash(S.encode('utf8')).digest()
|
||||||
|
return base64.b64encode(binary).decode('utf-8')
|
||||||
|
|
||||||
|
def update_caps(self, jid=None, node=None):
|
||||||
|
try:
|
||||||
|
info = self.xmpp['xep_0030'].get_info(jid, node, local=True)
|
||||||
|
if isinstance(info, Iq):
|
||||||
|
info = info['disco_info']
|
||||||
|
ver = self.generate_verstring(info, self.hash)
|
||||||
|
self.xmpp['xep_0030'].set_info(
|
||||||
|
jid=jid,
|
||||||
|
node='%s#%s' % (self.caps_node, ver),
|
||||||
|
info=info)
|
||||||
|
self.cache_caps(ver, info)
|
||||||
|
self.assign_verstring(jid, ver)
|
||||||
|
except XMPPError:
|
||||||
|
return
|
||||||
|
|
||||||
|
def get_verstring(self, jid=None):
|
||||||
|
if jid in ('', None):
|
||||||
|
jid = self.xmpp.boundjid.full
|
||||||
|
if isinstance(jid, JID):
|
||||||
|
jid = jid.full
|
||||||
|
return self._run_node_handler('get_verstring', jid)
|
||||||
|
|
||||||
|
def assign_verstring(self, jid=None, verstring=None):
|
||||||
|
if jid in (None, ''):
|
||||||
|
jid = self.xmpp.boundjid.full
|
||||||
|
if isinstance(jid, JID):
|
||||||
|
jid = jid.full
|
||||||
|
return self._run_node_handler('assign_verstring', jid,
|
||||||
|
data={'verstring': verstring})
|
||||||
|
|
||||||
|
def cache_caps(self, verstring=None, info=None):
|
||||||
|
data = {'verstring': verstring, 'info': info}
|
||||||
|
return self._run_node_handler('cache_caps', None, None, data=data)
|
||||||
|
|
||||||
|
def get_caps(self, jid=None, verstring=None):
|
||||||
|
if verstring is None:
|
||||||
|
if jid is not None:
|
||||||
|
verstring = self.get_verstring(jid)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
if isinstance(jid, JID):
|
||||||
|
jid = jid.full
|
||||||
|
data = {'verstring': verstring}
|
||||||
|
return self._run_node_handler('get_caps', jid, None, None, data)
|
19
sleekxmpp/plugins/xep_0115/stanza.py
Normal file
19
sleekxmpp/plugins/xep_0115/stanza.py
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
"""
|
||||||
|
SleekXMPP: The Sleek XMPP Library
|
||||||
|
Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout
|
||||||
|
This file is part of SleekXMPP.
|
||||||
|
|
||||||
|
See the file LICENSE for copying permission.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from sleekxmpp.xmlstream import ElementBase, ET
|
||||||
|
|
||||||
|
|
||||||
|
class Capabilities(ElementBase):
|
||||||
|
|
||||||
|
namespace = 'http://jabber.org/protocol/caps'
|
||||||
|
name = 'c'
|
||||||
|
plugin_attrib = 'caps'
|
||||||
|
interfaces = set(('hash', 'node', 'ver', 'ext'))
|
147
sleekxmpp/plugins/xep_0115/static.py
Normal file
147
sleekxmpp/plugins/xep_0115/static.py
Normal file
|
@ -0,0 +1,147 @@
|
||||||
|
"""
|
||||||
|
SleekXMPP: The Sleek XMPP Library
|
||||||
|
Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout
|
||||||
|
This file is part of SleekXMPP.
|
||||||
|
|
||||||
|
See the file LICENSE for copying permission.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import sleekxmpp
|
||||||
|
from sleekxmpp.xmlstream import JID
|
||||||
|
from sleekxmpp.plugins.xep_0030 import StaticDisco
|
||||||
|
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class StaticCaps(object):
|
||||||
|
|
||||||
|
"""
|
||||||
|
Extend the default StaticDisco implementation to provide
|
||||||
|
support for extended identity information.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, xmpp, static):
|
||||||
|
"""
|
||||||
|
Augment the default XEP-0030 static handler object.
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
static -- The default static XEP-0030 handler object.
|
||||||
|
"""
|
||||||
|
self.xmpp = xmpp
|
||||||
|
self.disco = self.xmpp['xep_0030']
|
||||||
|
self.caps = self.xmpp['xep_0115']
|
||||||
|
self.static = static
|
||||||
|
self.ver_cache = {}
|
||||||
|
self.jid_vers = {}
|
||||||
|
|
||||||
|
def supports(self, jid, node, ifrom, data):
|
||||||
|
"""
|
||||||
|
Check if a JID supports a given feature.
|
||||||
|
|
||||||
|
The data parameter may provide:
|
||||||
|
feature -- The feature to check for support.
|
||||||
|
local -- If true, then the query is for a JID/node
|
||||||
|
combination handled by this Sleek instance and
|
||||||
|
no stanzas need to be sent.
|
||||||
|
Otherwise, a disco stanza must be sent to the
|
||||||
|
remove JID to retrieve the info.
|
||||||
|
cached -- If true, then look for the disco info data from
|
||||||
|
the local cache system. If no results are found,
|
||||||
|
send the query as usual. The self.use_cache
|
||||||
|
setting must be set to true for this option to
|
||||||
|
be useful. If set to false, then the cache will
|
||||||
|
be skipped, even if a result has already been
|
||||||
|
cached. Defaults to false.
|
||||||
|
"""
|
||||||
|
feature = data.get('feature', None)
|
||||||
|
|
||||||
|
data = {'local': data.get('local', False),
|
||||||
|
'cached': data.get('cached', True)}
|
||||||
|
|
||||||
|
if not feature:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if node in (None, ''):
|
||||||
|
info = self.caps.get_caps(jid)
|
||||||
|
if info and feature in info['features']:
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
info = self.disco.get_info(jid=jid, node=node,
|
||||||
|
ifrom=ifrom, **data)
|
||||||
|
info = self.disco._wrap(ifrom, jid, info, True)
|
||||||
|
return feature in info['disco_info']['features']
|
||||||
|
except IqError:
|
||||||
|
return False
|
||||||
|
except IqTimeout:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def has_identity(self, jid, node, ifrom, data):
|
||||||
|
"""
|
||||||
|
Check if a JID has a given identity.
|
||||||
|
|
||||||
|
The data parameter may provide:
|
||||||
|
category -- The category of the identity to check.
|
||||||
|
itype -- The type of the identity to check.
|
||||||
|
lang -- The language of the identity to check.
|
||||||
|
local -- If true, then the query is for a JID/node
|
||||||
|
combination handled by this Sleek instance and
|
||||||
|
no stanzas need to be sent.
|
||||||
|
Otherwise, a disco stanza must be sent to the
|
||||||
|
remove JID to retrieve the info.
|
||||||
|
cached -- If true, then look for the disco info data from
|
||||||
|
the local cache system. If no results are found,
|
||||||
|
send the query as usual. The self.use_cache
|
||||||
|
setting must be set to true for this option to
|
||||||
|
be useful. If set to false, then the cache will
|
||||||
|
be skipped, even if a result has already been
|
||||||
|
cached. Defaults to false.
|
||||||
|
"""
|
||||||
|
identity = (data.get('category', None),
|
||||||
|
data.get('itype', None),
|
||||||
|
data.get('lang', None))
|
||||||
|
|
||||||
|
data = {'local': data.get('local', False),
|
||||||
|
'cached': data.get('cached', True)}
|
||||||
|
|
||||||
|
trunc = lambda i: (i[0], i[1], i[2])
|
||||||
|
|
||||||
|
if node in (None, ''):
|
||||||
|
info = self.caps.get_caps(jid)
|
||||||
|
if info and identity in map(trunc, info['identities']):
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
info = self.disco.get_info(jid=jid, node=node,
|
||||||
|
ifrom=ifrom, **data)
|
||||||
|
info = self.disco._wrap(ifrom, jid, info, True)
|
||||||
|
return identity in map(trunc, info['disco_info']['identities'])
|
||||||
|
except IqError:
|
||||||
|
return False
|
||||||
|
except IqTimeout:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def cache_caps(self, jid, node, ifrom, data):
|
||||||
|
with self.static.lock:
|
||||||
|
verstring = data.get('verstring', None)
|
||||||
|
info = data.get('info', None)
|
||||||
|
if not verstring or not info:
|
||||||
|
return
|
||||||
|
self.ver_cache[verstring] = info
|
||||||
|
|
||||||
|
def assign_verstring(self, jid, node, ifrom, data):
|
||||||
|
with self.static.lock:
|
||||||
|
if isinstance(jid, JID):
|
||||||
|
jid = jid.full
|
||||||
|
self.jid_vers[jid] = data.get('verstring', None)
|
||||||
|
|
||||||
|
def get_verstring(self, jid, node, ifrom, data):
|
||||||
|
with self.static.lock:
|
||||||
|
return self.jid_vers.get(jid, None)
|
||||||
|
|
||||||
|
def get_caps(self, jid, node, ifrom, data):
|
||||||
|
with self.static.lock:
|
||||||
|
return self.ver_cache.get(data.get('verstring', None), None)
|
|
@ -76,7 +76,7 @@ class xep_0128(base_plugin):
|
||||||
as extended information, replacing any
|
as extended information, replacing any
|
||||||
existing extensions.
|
existing extensions.
|
||||||
"""
|
"""
|
||||||
self.disco._run_node_handler('set_extended_info', jid, node, kwargs)
|
self.disco._run_node_handler('set_extended_info', jid, node, None, kwargs)
|
||||||
|
|
||||||
def add_extended_info(self, jid=None, node=None, **kwargs):
|
def add_extended_info(self, jid=None, node=None, **kwargs):
|
||||||
"""
|
"""
|
||||||
|
@ -88,7 +88,7 @@ class xep_0128(base_plugin):
|
||||||
data -- Either a form, or a list of forms to add
|
data -- Either a form, or a list of forms to add
|
||||||
as extended information.
|
as extended information.
|
||||||
"""
|
"""
|
||||||
self.disco._run_node_handler('add_extended_info', jid, node, kwargs)
|
self.disco._run_node_handler('add_extended_info', jid, node, None, kwargs)
|
||||||
|
|
||||||
def del_extended_info(self, jid=None, node=None, **kwargs):
|
def del_extended_info(self, jid=None, node=None, **kwargs):
|
||||||
"""
|
"""
|
||||||
|
@ -98,4 +98,4 @@ class xep_0128(base_plugin):
|
||||||
jid -- The JID to modify.
|
jid -- The JID to modify.
|
||||||
node -- The node to modify.
|
node -- The node to modify.
|
||||||
"""
|
"""
|
||||||
self.disco._run_node_handler('del_extended_info', jid, node, kwargs)
|
self.disco._run_node_handler('del_extended_info', jid, node, None, kwargs)
|
||||||
|
|
|
@ -31,42 +31,43 @@ class StaticExtendedDisco(object):
|
||||||
"""
|
"""
|
||||||
self.static = static
|
self.static = static
|
||||||
|
|
||||||
def set_extended_info(self, jid, node, data):
|
def set_extended_info(self, jid, node, ifrom, data):
|
||||||
"""
|
"""
|
||||||
Replace the extended identity data for a JID/node combination.
|
Replace the extended identity data for a JID/node combination.
|
||||||
|
|
||||||
The data parameter may provide:
|
The data parameter may provide:
|
||||||
data -- Either a single data form, or a list of data forms.
|
data -- Either a single data form, or a list of data forms.
|
||||||
"""
|
"""
|
||||||
self.del_extended_info(jid, node, data)
|
with self.static.lock:
|
||||||
self.add_extended_info(jid, node, data)
|
self.del_extended_info(jid, node, ifrom, data)
|
||||||
|
self.add_extended_info(jid, node, ifrom, data)
|
||||||
|
|
||||||
def add_extended_info(self, jid, node, data):
|
def add_extended_info(self, jid, node, ifrom, data):
|
||||||
"""
|
"""
|
||||||
Add additional extended identity data for a JID/node combination.
|
Add additional extended identity data for a JID/node combination.
|
||||||
|
|
||||||
The data parameter may provide:
|
The data parameter may provide:
|
||||||
data -- Either a single data form, or a list of data forms.
|
data -- Either a single data form, or a list of data forms.
|
||||||
"""
|
"""
|
||||||
self.static.add_node(jid, node)
|
with self.static.lock:
|
||||||
|
self.static.add_node(jid, node)
|
||||||
|
|
||||||
forms = data.get('data', [])
|
forms = data.get('data', [])
|
||||||
if not isinstance(forms, list):
|
if not isinstance(forms, list):
|
||||||
forms = [forms]
|
forms = [forms]
|
||||||
|
|
||||||
for form in forms:
|
info = self.static.get_node(jid, node)['info']
|
||||||
self.static.nodes[(jid, node)]['info'].append(form)
|
for form in forms:
|
||||||
|
info.append(form)
|
||||||
|
|
||||||
def del_extended_info(self, jid, node, data):
|
def del_extended_info(self, jid, node, ifrom, data):
|
||||||
"""
|
"""
|
||||||
Replace the extended identity data for a JID/node combination.
|
Replace the extended identity data for a JID/node combination.
|
||||||
|
|
||||||
The data parameter is not used.
|
The data parameter is not used.
|
||||||
"""
|
"""
|
||||||
if (jid, node) not in self.static.nodes:
|
with self.static.lock:
|
||||||
return
|
if self.static.node_exists(jid, node):
|
||||||
|
info = self.static.get_node(jid, node)['info']
|
||||||
info = self.static.nodes[(jid, node)]['info']
|
for form in info['substanza']:
|
||||||
|
info.xml.remove(form.xml)
|
||||||
for form in info['substanza']:
|
|
||||||
info.xml.remove(form.xml)
|
|
||||||
|
|
|
@ -345,7 +345,8 @@ class ElementBase(object):
|
||||||
"""
|
"""
|
||||||
if attrib not in self.plugins:
|
if attrib not in self.plugins:
|
||||||
plugin_class = self.plugin_attrib_map[attrib]
|
plugin_class = self.plugin_attrib_map[attrib]
|
||||||
plugin = plugin_class(parent=self)
|
existing_xml = self.xml.find(plugin_class.tag_name())
|
||||||
|
plugin = plugin_class(parent=self, xml=existing_xml)
|
||||||
self.plugins[attrib] = plugin
|
self.plugins[attrib] = plugin
|
||||||
if plugin_class in self.plugin_iterables:
|
if plugin_class in self.plugin_iterables:
|
||||||
self.iterables.append(plugin)
|
self.iterables.append(plugin)
|
||||||
|
@ -1251,7 +1252,7 @@ class StanzaBase(ElementBase):
|
||||||
stanza sent immediately. Useful for stream
|
stanza sent immediately. Useful for stream
|
||||||
initialization. Defaults to ``False``.
|
initialization. Defaults to ``False``.
|
||||||
"""
|
"""
|
||||||
self.stream.send_raw(self.__str__(), now=now)
|
self.stream.send(self, now=now)
|
||||||
|
|
||||||
def __copy__(self):
|
def __copy__(self):
|
||||||
"""Return a copy of the stanza object that does not share the
|
"""Return a copy of the stanza object that does not share the
|
||||||
|
|
|
@ -35,7 +35,7 @@ except ImportError:
|
||||||
import sleekxmpp
|
import sleekxmpp
|
||||||
from sleekxmpp.thirdparty.statemachine import StateMachine
|
from sleekxmpp.thirdparty.statemachine import StateMachine
|
||||||
from sleekxmpp.xmlstream import Scheduler, tostring
|
from sleekxmpp.xmlstream import Scheduler, tostring
|
||||||
from sleekxmpp.xmlstream.stanzabase import StanzaBase, ET
|
from sleekxmpp.xmlstream.stanzabase import StanzaBase, ET, ElementBase
|
||||||
from sleekxmpp.xmlstream.handler import Waiter, XMLCallback
|
from sleekxmpp.xmlstream.handler import Waiter, XMLCallback
|
||||||
from sleekxmpp.xmlstream.matcher import MatchXMLMask
|
from sleekxmpp.xmlstream.matcher import MatchXMLMask
|
||||||
|
|
||||||
|
@ -268,6 +268,7 @@ class XMLStream(object):
|
||||||
self.__handlers = []
|
self.__handlers = []
|
||||||
self.__event_handlers = {}
|
self.__event_handlers = {}
|
||||||
self.__event_handlers_lock = threading.Lock()
|
self.__event_handlers_lock = threading.Lock()
|
||||||
|
self.__filters = {'in': [], 'out': []}
|
||||||
|
|
||||||
self._id = 0
|
self._id = 0
|
||||||
self._id_lock = threading.Lock()
|
self._id_lock = threading.Lock()
|
||||||
|
@ -743,6 +744,28 @@ class XMLStream(object):
|
||||||
"""
|
"""
|
||||||
del self.__root_stanza[stanza_class]
|
del self.__root_stanza[stanza_class]
|
||||||
|
|
||||||
|
def add_filter(self, mode, handler, order=None):
|
||||||
|
"""Add a filter for incoming or outgoing stanzas.
|
||||||
|
|
||||||
|
These filters are applied before incoming stanzas are
|
||||||
|
passed to any handlers, and before outgoing stanzas
|
||||||
|
are put in the send queue.
|
||||||
|
|
||||||
|
Each filter must accept a single stanza, and return
|
||||||
|
either a stanza or ``None``. If the filter returns
|
||||||
|
``None``, then the stanza will be dropped from being
|
||||||
|
processed for events or from being sent.
|
||||||
|
|
||||||
|
:param mode: One of ``'in'`` or ``'out'``.
|
||||||
|
:param handler: The filter function.
|
||||||
|
:param int order: The position to insert the filter in
|
||||||
|
the list of active filters.
|
||||||
|
"""
|
||||||
|
if order:
|
||||||
|
self.__filters[mode].insert(order, handler)
|
||||||
|
else:
|
||||||
|
self.__filters[mode].append(handler)
|
||||||
|
|
||||||
def add_handler(self, mask, pointer, name=None, disposable=False,
|
def add_handler(self, mask, pointer, name=None, disposable=False,
|
||||||
threaded=False, filter=False, instream=False):
|
threaded=False, filter=False, instream=False):
|
||||||
"""A shortcut method for registering a handler using XML masks.
|
"""A shortcut method for registering a handler using XML masks.
|
||||||
|
@ -994,6 +1017,14 @@ class XMLStream(object):
|
||||||
timeout = self.response_timeout
|
timeout = self.response_timeout
|
||||||
if hasattr(mask, 'xml'):
|
if hasattr(mask, 'xml'):
|
||||||
mask = mask.xml
|
mask = mask.xml
|
||||||
|
|
||||||
|
if isinstance(data, ElementBase):
|
||||||
|
for filter in self.__filters['out']:
|
||||||
|
if data is not None:
|
||||||
|
data = filter(data)
|
||||||
|
if data is None:
|
||||||
|
return
|
||||||
|
|
||||||
data = str(data)
|
data = str(data)
|
||||||
if mask is not None:
|
if mask is not None:
|
||||||
log.warning("Use of send mask waiters is deprecated.")
|
log.warning("Use of send mask waiters is deprecated.")
|
||||||
|
@ -1246,8 +1277,6 @@ class XMLStream(object):
|
||||||
:param xml: The :class:`~sleekxmpp.xmlstream.stanzabase.ElementBase`
|
:param xml: The :class:`~sleekxmpp.xmlstream.stanzabase.ElementBase`
|
||||||
stanza to analyze.
|
stanza to analyze.
|
||||||
"""
|
"""
|
||||||
log.debug("RECV: %s", tostring(xml, xmlns=self.default_ns,
|
|
||||||
stream=self))
|
|
||||||
# Apply any preprocessing filters.
|
# Apply any preprocessing filters.
|
||||||
xml = self.incoming_filter(xml)
|
xml = self.incoming_filter(xml)
|
||||||
|
|
||||||
|
@ -1255,6 +1284,15 @@ class XMLStream(object):
|
||||||
# stanza type applies, a generic StanzaBase stanza will be used.
|
# stanza type applies, a generic StanzaBase stanza will be used.
|
||||||
stanza = self._build_stanza(xml)
|
stanza = self._build_stanza(xml)
|
||||||
|
|
||||||
|
for filter in self.__filters['in']:
|
||||||
|
if stanza is not None:
|
||||||
|
stanza = filter(stanza)
|
||||||
|
if stanza is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
log.debug("RECV: %s", tostring(xml, xmlns=self.default_ns,
|
||||||
|
stream=self))
|
||||||
|
|
||||||
# Match the stanza against registered handlers. Handlers marked
|
# Match the stanza against registered handlers. Handlers marked
|
||||||
# to run "in stream" will be executed immediately; the rest will
|
# to run "in stream" will be executed immediately; the rest will
|
||||||
# be queued.
|
# be queued.
|
||||||
|
|
88
tests/test_stream_filters.py
Normal file
88
tests/test_stream_filters.py
Normal file
|
@ -0,0 +1,88 @@
|
||||||
|
import time
|
||||||
|
|
||||||
|
from sleekxmpp import Message
|
||||||
|
from sleekxmpp.test import *
|
||||||
|
from sleekxmpp.xmlstream.handler import *
|
||||||
|
from sleekxmpp.xmlstream.matcher import *
|
||||||
|
|
||||||
|
|
||||||
|
class TestFilters(SleekTest):
|
||||||
|
|
||||||
|
"""
|
||||||
|
Test using incoming and outgoing filters.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.stream_start()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.stream_close()
|
||||||
|
|
||||||
|
def testIncoming(self):
|
||||||
|
|
||||||
|
data = []
|
||||||
|
|
||||||
|
def in_filter(stanza):
|
||||||
|
if isinstance(stanza, Message):
|
||||||
|
if stanza['body'] == 'testing':
|
||||||
|
stanza['subject'] = stanza['body'] + ' filter'
|
||||||
|
print('>>> %s' % stanza['subject'])
|
||||||
|
return stanza
|
||||||
|
|
||||||
|
def on_message(msg):
|
||||||
|
print('<<< %s' % msg['subject'])
|
||||||
|
data.append(msg['subject'])
|
||||||
|
|
||||||
|
self.xmpp.add_filter('in', in_filter)
|
||||||
|
self.xmpp.add_event_handler('message', on_message)
|
||||||
|
|
||||||
|
self.recv("""
|
||||||
|
<message>
|
||||||
|
<body>no filter</body>
|
||||||
|
</message>
|
||||||
|
""")
|
||||||
|
|
||||||
|
self.recv("""
|
||||||
|
<message>
|
||||||
|
<body>testing</body>
|
||||||
|
</message>
|
||||||
|
""")
|
||||||
|
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
self.assertEqual(data, ['', 'testing filter'],
|
||||||
|
'Incoming filter did not apply %s' % data)
|
||||||
|
|
||||||
|
def testOutgoing(self):
|
||||||
|
|
||||||
|
def out_filter(stanza):
|
||||||
|
if isinstance(stanza, Message):
|
||||||
|
if stanza['body'] == 'testing':
|
||||||
|
stanza['body'] = 'changed!'
|
||||||
|
return stanza
|
||||||
|
|
||||||
|
self.xmpp.add_filter('out', out_filter)
|
||||||
|
|
||||||
|
m1 = self.Message()
|
||||||
|
m1['body'] = 'testing'
|
||||||
|
m1.send()
|
||||||
|
|
||||||
|
m2 = self.Message()
|
||||||
|
m2['body'] = 'blah'
|
||||||
|
m2.send()
|
||||||
|
|
||||||
|
self.send("""
|
||||||
|
<message>
|
||||||
|
<body>changed!</body>
|
||||||
|
</message>
|
||||||
|
""")
|
||||||
|
|
||||||
|
self.send("""
|
||||||
|
<message>
|
||||||
|
<body>blah</body>
|
||||||
|
</message>
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
suite = unittest.TestLoader().loadTestsFromTestCase(TestFilters)
|
|
@ -122,7 +122,7 @@ class TestStreamDisco(SleekTest):
|
||||||
self.stream_start(mode='client',
|
self.stream_start(mode='client',
|
||||||
plugins=['xep_0030'])
|
plugins=['xep_0030'])
|
||||||
|
|
||||||
def dynamic_jid(jid, node, iq):
|
def dynamic_jid(jid, node, ifrom, iq):
|
||||||
result = self.xmpp['xep_0030'].stanza.DiscoInfo()
|
result = self.xmpp['xep_0030'].stanza.DiscoInfo()
|
||||||
result['node'] = node
|
result['node'] = node
|
||||||
result.add_identity('client', 'console', name='Dynamic Info')
|
result.add_identity('client', 'console', name='Dynamic Info')
|
||||||
|
@ -158,7 +158,7 @@ class TestStreamDisco(SleekTest):
|
||||||
jid='tester.localhost',
|
jid='tester.localhost',
|
||||||
plugins=['xep_0030'])
|
plugins=['xep_0030'])
|
||||||
|
|
||||||
def dynamic_global(jid, node, iq):
|
def dynamic_global(jid, node, ifrom, iq):
|
||||||
result = self.xmpp['xep_0030'].stanza.DiscoInfo()
|
result = self.xmpp['xep_0030'].stanza.DiscoInfo()
|
||||||
result['node'] = node
|
result['node'] = node
|
||||||
result.add_identity('component', 'generic', name='Dynamic Info')
|
result.add_identity('component', 'generic', name='Dynamic Info')
|
||||||
|
@ -194,7 +194,7 @@ class TestStreamDisco(SleekTest):
|
||||||
self.stream_start(mode='client',
|
self.stream_start(mode='client',
|
||||||
plugins=['xep_0030'])
|
plugins=['xep_0030'])
|
||||||
|
|
||||||
def dynamic_jid(jid, node, iq):
|
def dynamic_jid(jid, node, ifrom, iq):
|
||||||
result = self.xmpp['xep_0030'].stanza.DiscoInfo()
|
result = self.xmpp['xep_0030'].stanza.DiscoInfo()
|
||||||
result['node'] = node
|
result['node'] = node
|
||||||
result.add_identity('client', 'console', name='Dynamic Info')
|
result.add_identity('client', 'console', name='Dynamic Info')
|
||||||
|
@ -236,7 +236,7 @@ class TestStreamDisco(SleekTest):
|
||||||
jid='tester.localhost',
|
jid='tester.localhost',
|
||||||
plugins=['xep_0030'])
|
plugins=['xep_0030'])
|
||||||
|
|
||||||
def dynamic_global(jid, node, iq):
|
def dynamic_global(jid, node, ifrom, iq):
|
||||||
result = self.xmpp['xep_0030'].stanza.DiscoInfo()
|
result = self.xmpp['xep_0030'].stanza.DiscoInfo()
|
||||||
result['node'] = node
|
result['node'] = node
|
||||||
result.add_identity('component', 'generic', name='Dynamic Info')
|
result.add_identity('component', 'generic', name='Dynamic Info')
|
||||||
|
@ -325,7 +325,7 @@ class TestStreamDisco(SleekTest):
|
||||||
self.stream_start(mode='client',
|
self.stream_start(mode='client',
|
||||||
plugins=['xep_0030'])
|
plugins=['xep_0030'])
|
||||||
|
|
||||||
def dynamic_jid(jid, node, iq):
|
def dynamic_jid(jid, node, ifrom, iq):
|
||||||
result = self.xmpp['xep_0030'].stanza.DiscoItems()
|
result = self.xmpp['xep_0030'].stanza.DiscoItems()
|
||||||
result['node'] = node
|
result['node'] = node
|
||||||
result.add_item('tester@localhost', node='foo', name='JID')
|
result.add_item('tester@localhost', node='foo', name='JID')
|
||||||
|
@ -359,7 +359,7 @@ class TestStreamDisco(SleekTest):
|
||||||
jid='tester.localhost',
|
jid='tester.localhost',
|
||||||
plugins=['xep_0030'])
|
plugins=['xep_0030'])
|
||||||
|
|
||||||
def dynamic_global(jid, node, iq):
|
def dynamic_global(jid, node, ifrom, iq):
|
||||||
result = self.xmpp['xep_0030'].stanza.DiscoItems()
|
result = self.xmpp['xep_0030'].stanza.DiscoItems()
|
||||||
result['node'] = node
|
result['node'] = node
|
||||||
result.add_item('tester@localhost', node='foo', name='Global')
|
result.add_item('tester@localhost', node='foo', name='Global')
|
||||||
|
@ -393,7 +393,7 @@ class TestStreamDisco(SleekTest):
|
||||||
self.stream_start(mode='client',
|
self.stream_start(mode='client',
|
||||||
plugins=['xep_0030'])
|
plugins=['xep_0030'])
|
||||||
|
|
||||||
def dynamic_jid(jid, node, iq):
|
def dynamic_jid(jid, node, ifrom, iq):
|
||||||
result = self.xmpp['xep_0030'].stanza.DiscoItems()
|
result = self.xmpp['xep_0030'].stanza.DiscoItems()
|
||||||
result['node'] = node
|
result['node'] = node
|
||||||
result.add_item('tester@localhost', node='foo', name='Global')
|
result.add_item('tester@localhost', node='foo', name='Global')
|
||||||
|
@ -435,7 +435,7 @@ class TestStreamDisco(SleekTest):
|
||||||
jid='tester.localhost',
|
jid='tester.localhost',
|
||||||
plugins=['xep_0030'])
|
plugins=['xep_0030'])
|
||||||
|
|
||||||
def dynamic_global(jid, node, iq):
|
def dynamic_global(jid, node, ifrom, iq):
|
||||||
result = self.xmpp['xep_0030'].stanza.DiscoItems()
|
result = self.xmpp['xep_0030'].stanza.DiscoItems()
|
||||||
result['node'] = node
|
result['node'] = node
|
||||||
result.add_item('tester.localhost', node='foo', name='Global')
|
result.add_item('tester.localhost', node='foo', name='Global')
|
||||||
|
|
Loading…
Reference in a new issue