diff options
55 files changed, 1289 insertions, 435 deletions
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 48c6be9a..ebcc24eb 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -20,7 +20,30 @@ test: script: - apt update - apt install -y python3 python3-pip cython3 gpg - - pip3 install emoji aiohttp + - pip3 install emoji aiohttp cryptography + - ./run_tests.py + +test-3.10: + stage: test + tags: + - docker + image: python:3.10 + script: + - apt update + - apt install -y python3 python3-pip cython3 gpg + - pip3 install emoji aiohttp cryptography + - ./run_tests.py + +test-3.11: + stage: test + tags: + - docker + image: python:3.11-rc + allow_failure: true + script: + - apt update + - apt install -y python3 python3-pip cython3 gpg + - pip3 install emoji aiohttp cryptography - ./run_tests.py test_integration: @@ -42,6 +65,6 @@ trigger_poezio: stage: trigger tags: - docker - image: appropriate/curl:latest + image: curlimages/curl:7.79.1 script: - curl --request POST -F token="$SLIXMPP_TRIGGER_TOKEN" -F ref=master https://lab.louiz.org/api/v4/projects/18/trigger/pipeline @@ -457,6 +457,14 @@ </implements> <implements> <xmpp:SupportedXep> + <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0175.html"/> + <xmpp:status>complete</xmpp:status> + <xmpp:version>1.2</xmpp:version> + <xmpp:since>1.0</xmpp:since> + </xmpp:SupportedXep> + </implements> + <implements> + <xmpp:SupportedXep> <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0184.html"/> <xmpp:status>complete</xmpp:status> <xmpp:version>1.4.0</xmpp:version> @@ -892,6 +900,14 @@ <xmpp:since>1.6.0</xmpp:since> </xmpp:SupportedXep> </implements> + <implements> + <xmpp:SupportedXep> + <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0454.html"/> + <xmpp:status>no thumbnail support</xmpp:status> + <xmpp:version>0.1.0</xmpp:version> + <xmpp:since>1.8.1</xmpp:since> + </xmpp:SupportedXep> + </implements> <release> <Version> @@ -1002,7 +1018,35 @@ <Version> <revision>1.7.0</revision> <created>2021-01-29</created> - <file-release rdf:resource="https://lab.louiz.org/poezio/slixmpp/-/archive/slix-1.7.9/slixmpp-slix-1.7.9.tar.gz"/> + <file-release rdf:resource="https://lab.louiz.org/poezio/slixmpp/-/archive/slix-1.7.0/slixmpp-slix-1.7.0.tar.gz"/> + </Version> + </release> + <release> + <Version> + <revision>1.7.1</revision> + <created>2021-04-30</created> + <file-release rdf:resource="https://lab.louiz.org/poezio/slixmpp/-/archive/slix-1.7.1/slixmpp-slix-1.7.1.tar.gz"/> + </Version> + </release> + <release> + <Version> + <revision>1.8.0</revision> + <created>2022-02-27</created> + <file-release rdf:resource="https://lab.louiz.org/poezio/slixmpp/-/archive/slix-1.8.0/slixmpp-slix-1.8.0.tar.gz"/> + </Version> + </release> + <release> + <Version> + <revision>1.8.1</revision> + <created>2022-03-20</created> + <file-release rdf:resource="https://lab.louiz.org/poezio/slixmpp/-/archive/slix-1.8.1/slixmpp-slix-1.8.1.tar.gz"/> + </Version> + </release> + <release> + <Version> + <revision>1.8.2</revision> + <created>2022-04-06</created> + <file-release rdf:resource="https://lab.louiz.org/poezio/slixmpp/-/archive/slix-1.8.2/slixmpp-slix-1.8.2.tar.gz"/> </Version> </release> </Project> diff --git a/docs/api/plugins/xep_0055.rst b/docs/api/plugins/xep_0055.rst new file mode 100644 index 00000000..75abe991 --- /dev/null +++ b/docs/api/plugins/xep_0055.rst @@ -0,0 +1,18 @@ + +XEP-0055: Jabber search +======================= + +.. module:: slixmpp.plugins.xep_0055 + +.. autoclass:: XEP_0055 + :members: + :exclude-members: session_bind, plugin_init, plugin_end + + +Stanza elements +--------------- + +.. automodule:: slixmpp.plugins.xep_0055.stanza + :members: + :undoc-members: + diff --git a/docs/howto/guide_xep_0030.rst b/docs/howto/guide_xep_0030.rst index a3af4857..4b795c93 100644 --- a/docs/howto/guide_xep_0030.rst +++ b/docs/howto/guide_xep_0030.rst @@ -172,14 +172,14 @@ the `XEP-0059 <http://xmpp.org/extensions/xep-0059.html>`_ plug-in. .. code-block:: python - info = yield from self['xep_0030'].get_info(jid='foo@example.com', - node='bar', - ifrom='baz@mycomponent.example.com', - timeout=30) + info = await self['xep_0030'].get_info(jid='foo@example.com', + node='bar', + ifrom='baz@mycomponent.example.com', + timeout=30) - items = self['xep_0030'].get_info(jid='foo@example.com', - node='bar', - iterator=True) + items = await self['xep_0030'].get_items(jid='foo@example.com', + node='bar', + iterator=True) For more examples on how to use basic disco queries, check the ``disco_browser.py`` example in the ``examples`` directory. @@ -194,7 +194,7 @@ a full Iq stanza. .. code-block:: python - info = self['xep_0030'].get_info(node='foo', local=True) - items = self['xep_0030'].get_items(jid='somejid@mycomponent.example.com', - node='bar', - local=True) + info = await self['xep_0030'].get_info(node='foo', local=True) + items = await self['xep_0030'].get_items(jid='somejid@mycomponent.example.com', + node='bar', + local=True) diff --git a/docs/howto/index.rst b/docs/howto/index.rst index e4dee4d7..a4f0f3b0 100644 --- a/docs/howto/index.rst +++ b/docs/howto/index.rst @@ -9,6 +9,7 @@ Tutorials, FAQs, and How To Guides internal_api features sasl + remove_process handlersmatchers guide_xep_0030 xmpp_tdg diff --git a/docs/howto/remove_process.rst b/docs/howto/remove_process.rst new file mode 100644 index 00000000..26d54a62 --- /dev/null +++ b/docs/howto/remove_process.rst @@ -0,0 +1,55 @@ +.. _remove-process: + +How to remove xmpp.process() +============================ + + +Starting from slixmpp 1.8.0, running ``process()`` on an +XMLStream/ClientXMPP/ComponentXMPP instance is deprecated, and starting from +1.9.0, it will be removed. + +Why +--- + +This has been the usual way of running an application using SleekXMPP/slixmpp +for ages, but it has come at a price: people do not understand how they +should run their application without it, or how to integrate their slixmpp +code with the rest of their asyncio application. + +In essence, ``process()`` is only a very thin wrapper around asyncio loop +functions: + +.. code-block:: python + + if timeout is None: + if forever: + self.loop.run_forever() + else: + self.loop.run_until_complete(self.disconnected) + else: + tasks: List[Future] = [asyncio.sleep(timeout)] + if not forever: + tasks.append(self.disconnected) + self.loop.run_until_complete(asyncio.wait(tasks)) + +How +--- + +Hence it can be replaced according to what you want your application to do: + +- To run forever, ``loop.run_forever()`` will work just fine + +- To run until disconnected, ``loop.run_until_complete(xmpp.disconnected)`` + will be enough (XMLStream.disconnected is an future which result is set when + the stream gets disconnected. + +- To run for a scheduled time (and still abort when disconnected): + +.. code-block:: python + + tasks = [asyncio.sleep(timeout)] + tasks.append(xmpp.disconnected) + loop.run_until_complete(asyncio.wait(tasks)) + +There is no magic at play here and anything is possible if a more flexible +execution scheme is expected. diff --git a/docs/howto/xmpp_tdg.rst b/docs/howto/xmpp_tdg.rst index 53194e13..d3b761d2 100644 --- a/docs/howto/xmpp_tdg.rst +++ b/docs/howto/xmpp_tdg.rst @@ -193,7 +193,7 @@ implementation should work correctly. .. tip:: To see how to implement in-band registration as a Slixmpp plugin, - see the tutorial :ref:`tutorial-create-plugin`. + see the tutorial :ref:`create-plugin`. `View full source (5) <http://github.com/legastero/xmpp-tdg/blob/master/code/CheshiR/RegistrableComponent.py>`_ | `View original code (5) <http://github.com/remko/xmpp-tdg/blob/master/code/CheshiR/RegistrableComponent.py>`_ diff --git a/docs/using_asyncio.rst b/docs/using_asyncio.rst index 55ed7679..ca85b7c6 100644 --- a/docs/using_asyncio.rst +++ b/docs/using_asyncio.rst @@ -11,7 +11,7 @@ Block on IQ sending .. code-block:: python - result = yield from iq.send() + result = await iq.send() .. warning:: @@ -28,13 +28,7 @@ The same changes from the SleekXMPP API apply, so you can do: .. code-block:: python - iq_info = yield from self.xmpp['xep_0030'].get_info(jid) - -But the following will only return a Future: - -.. code-block:: python - - iq_info = self.xmpp['xep_0030'].get_info(jid) + iq_info = await self.xmpp['xep_0030'].get_info(jid) Callbacks, Event Handlers, and Stream Handlers @@ -42,7 +36,7 @@ Callbacks, Event Handlers, and Stream Handlers IQ callbacks and :term:`Event Handlers <event handler>` can be coroutine functions; in this case, they will be scheduled in the event loop using -:meth:`.asyncio.async` and not ran immediately. +:meth:`.asyncio.ensure_future` and not ran immediately. A :class:`.CoroutineCallback` class has been added as well for :term:`Stream Handlers <stream handler>`, which will use @@ -94,18 +88,18 @@ a simple <message>. .. code-block:: python - import asyncio, aiohttp, slixmpp + import aiohttp, slixmpp - @asyncio.coroutine - def get_pythonorg(event): - req = yield from aiohttp.request('get', 'http://www.python.org') - text = yield from req.text + async def get_pythonorg(event): + async with aiohttp.ClientSession() as session: + async with session.get('http://www.python.org') as resp: + text = await req.text() client.send_message(mto='jid2@example', mbody=text) - @asyncio.coroutine - def get_asyncioorg(event): - req = yield from aiohttp.request('get', 'http://www.asyncio.org') - text = yield from req.text + async def get_asyncioorg(event): + async with aiohttp.ClientSession() as session: + async with session.get('http://www.asyncio.org') as resp: + text = await req.text() client.send_message(mto='jid3@example', mbody=text) client = slixmpp.ClientXMPP('jid@example', 'password') @@ -132,11 +126,10 @@ JID indicating its findings. self.register_plugin('xep_0092') self.add_event_handler('message', self.on_message) - @asyncio.coroutine - def on_message(self, event): + async def on_message(self, event): # You should probably handle IqError and IqTimeout exceptions here # but this is an example. - version = yield from self['xep_0092'].get_version(message['from']) + version = await self['xep_0092'].get_version(message['from']) text = "%s sent me a message, he runs %s" % (message['from'], version['software_version']['name']) self.send_message(mto='master@example.tld', mbody=text) diff --git a/examples/http_upload.py b/examples/http_upload.py index a926fd47..b62c736e 100755 --- a/examples/http_upload.py +++ b/examples/http_upload.py @@ -5,11 +5,16 @@ # This file is part of Slixmpp. # See the file LICENSE for copying permission. +from typing import Optional + +import sys import logging +from pathlib import Path from getpass import getpass from argparse import ArgumentParser import slixmpp +from slixmpp import JID from slixmpp.exceptions import IqTimeout log = logging.getLogger(__name__) @@ -21,20 +26,40 @@ class HttpUpload(slixmpp.ClientXMPP): A basic client asking an entity if they confirm the access to an HTTP URL. """ - def __init__(self, jid, password, recipient, filename, domain=None): + def __init__( + self, + jid: JID, + password: str, + recipient: JID, + filename: Path, + domain: Optional[JID] = None, + encrypted: bool = False, + ): slixmpp.ClientXMPP.__init__(self, jid, password) self.recipient = recipient self.filename = filename self.domain = domain + self.encrypted = encrypted self.add_event_handler("session_start", self.start) async def start(self, event): log.info('Uploading file %s...', self.filename) try: - url = await self['xep_0363'].upload_file( - self.filename, domain=self.domain, timeout=10 + upload_file = self['xep_0363'].upload_file + if self.encrypted and not self['xep_0454']: + print( + 'The xep_0454 module isn\'t available. ' + 'Ensure you have \'cryptography\' ' + 'from extras_require installed.', + file=sys.stderr, + ) + return + elif self.encrypted: + upload_file = self['xep_0454'].upload_file + url = await upload_file( + self.filename, domain=self.domain, timeout=10, ) except IqTimeout: raise TimeoutError('Could not send message in time') @@ -79,6 +104,10 @@ if __name__ == '__main__': parser.add_argument("--domain", help="Domain to use for HTTP File Upload (leave out for your own server’s)") + parser.add_argument("-e", "--encrypt", dest="encrypted", + help="Whether to encrypt", action="store_true", + default=False) + args = parser.parse_args() # Setup logging. @@ -86,15 +115,41 @@ if __name__ == '__main__': format='%(levelname)-8s %(message)s') if args.jid is None: - args.jid = input("Username: ") + args.jid = JID(input("Username: ")) if args.password is None: args.password = getpass("Password: ") - xmpp = HttpUpload(args.jid, args.password, args.recipient, args.file, args.domain) + domain = args.domain + if domain is not None: + domain = JID(domain) + + if args.encrypted: + print( + 'You are using the --encrypt flag. ' + 'Be aware that the transport being used is NOT end-to-end ' + 'encrypted. The server will be able to decrypt the file.', + file=sys.stderr, + ) + + xmpp = HttpUpload( + jid=args.jid, + password=args.password, + recipient=JID(args.recipient), + filename=Path(args.file), + domain=domain, + encrypted=args.encrypted, + ) xmpp.register_plugin('xep_0066') xmpp.register_plugin('xep_0071') xmpp.register_plugin('xep_0128') xmpp.register_plugin('xep_0363') + try: + xmpp.register_plugin('xep_0454') + except slixmpp.plugins.base.PluginNotFound: + log.error( + 'Could not load xep_0454. ' + 'Ensure you have \'cryptography\' from extras_require installed.' + ) # Connect to the XMPP server and start processing XMPP stanzas. xmpp.connect() diff --git a/itests/test_pep.py b/itests/test_pep.py index a674a348..382c22b6 100644 --- a/itests/test_pep.py +++ b/itests/test_pep.py @@ -34,7 +34,14 @@ class TestPEP(SlixIntegration): """Check we can get and set public PEP data""" stanza = Mystanza() stanza['test'] = str(uuid4().hex) - await self.clients[0]['xep_0222'].store(stanza, id='toto') + try: + await self.clients[0]['xep_0060'].delete_node( + self.clients[0].boundjid.bare, + node=stanza.namespace, + ) + except: + pass + await self.clients[0]['xep_0222'].store(stanza, node=stanza.namespace, id='toto') fetched = await self.clients[0]['xep_0222'].retrieve( stanza.namespace, ) @@ -86,10 +86,16 @@ setup( package_data={'slixmpp': ['py.typed']}, packages=packages, ext_modules=ext_modules, - install_requires=['aiodns>=1.0', 'pyasn1', 'pyasn1_modules', 'typing_extensions; python_version < "3.8.0"'], + install_requires=[ + 'aiodns>=1.0', + 'pyasn1', + 'pyasn1_modules', + 'typing_extensions; python_version < "3.8.0"', + ], extras_require={ 'XEP-0363': ['aiohttp'], 'XEP-0444 compliance': ['emoji'], + 'XEP-0454': ['cryptography'], 'Safer XML parsing': ['defusedxml'], }, classifiers=CLASSIFIERS, diff --git a/slixmpp/__init__.py b/slixmpp/__init__.py index 403c9299..5841cdf7 100644 --- a/slixmpp/__init__.py +++ b/slixmpp/__init__.py @@ -4,14 +4,17 @@ # This file is part of Slixmpp. # See the file LICENSE for copying permission. import logging -logging.getLogger(__name__).addHandler(logging.NullHandler()) +from os import getenv -# Use defusedxml if available -try: - import defusedxml - defusedxml.defuse_stdlib() -except ImportError: - pass +# Use defusedxml if wanted +# Since enabling it can have adverse consequences for the programs using +# slixmpp, do not enable it by default. +if getenv('SLIXMPP_ENABLE_DEFUSEDXML', default='false').lower() == 'true': + try: + import defusedxml + defusedxml.defuse_stdlib() + except ImportError: + pass from slixmpp.stanza import Message, Presence, Iq from slixmpp.jid import JID, InvalidJID diff --git a/slixmpp/basexmpp.py b/slixmpp/basexmpp.py index cd228312..c54ec63a 100644 --- a/slixmpp/basexmpp.py +++ b/slixmpp/basexmpp.py @@ -140,7 +140,7 @@ class BaseXMPP(XMLStream): self.use_presence_ids = True #: XEP-0359 <origin-id/> tag that gets added to <message/> stanzas. - self.use_origin_id = True + self.use_origin_id = False #: The API registry is a way to process callbacks based on #: JID+node combinations. Each callback in the registry is diff --git a/slixmpp/features/feature_starttls/stanza.py b/slixmpp/features/feature_starttls/stanza.py index 70979402..f433f1b2 100644 --- a/slixmpp/features/feature_starttls/stanza.py +++ b/slixmpp/features/feature_starttls/stanza.py @@ -3,8 +3,12 @@ # Copyright (C) 2011 Nathanael C. Fritz # This file is part of Slixmpp. # See the file LICENSE for copying permission. -from slixmpp.xmlstream import StanzaBase, ElementBase from typing import Set, ClassVar +from slixmpp.xmlstream import StanzaBase, ElementBase +from slixmpp.xmlstream.xmlstream import InvalidCABundle + +import logging +log = logging.getLogger(__name__) class STARTTLS(StanzaBase): @@ -36,6 +40,12 @@ class Proceed(StanzaBase): namespace = 'urn:ietf:params:xml:ns:xmpp-tls' interfaces: ClassVar[Set[str]] = set() + def exception(self, e: Exception) -> None: + log.exception('Error handling {%s}%s stanza', + self.namespace, self.name) + if isinstance(e, InvalidCABundle): + raise e + class Failure(StanzaBase): """ diff --git a/slixmpp/jid.py b/slixmpp/jid.py index adde95a4..c705a422 100644 --- a/slixmpp/jid.py +++ b/slixmpp/jid.py @@ -133,15 +133,15 @@ def _validate_domain(domain: str): try: domain = idna(domain) except StringprepError: - raise InvalidJID('idna validation failed') + raise InvalidJID(f'idna validation failed: {domain}') if ':' in domain: - raise InvalidJID('Domain containing a port') + raise InvalidJID(f'Domain containing a port: {domain}') for label in domain.split('.'): if not label: - raise InvalidJID('Domain containing too many dots') + raise InvalidJID(f'Domain containing too many dots: {domain}') if '-' in (label[0], label[-1]): - raise InvalidJID('Domain started or ended with -') + raise InvalidJID(f'Domain starting or ending with -: {domain}') if not domain: raise InvalidJID('Domain must not be 0 bytes') @@ -368,7 +368,7 @@ class JID: return self._node @node.setter - def node(self, value: str): + def node(self, value: Optional[str]): self._node = _validate_node(value) self._update_bare_full() @@ -386,7 +386,7 @@ class JID: return self._resource @resource.setter - def resource(self, value: str): + def resource(self, value: Optional[str]): self._resource = _validate_resource(value) self._update_bare_full() diff --git a/slixmpp/plugins/__init__.py b/slixmpp/plugins/__init__.py index 55627113..ac7482ee 100644 --- a/slixmpp/plugins/__init__.py +++ b/slixmpp/plugins/__init__.py @@ -1,4 +1,3 @@ - # Slixmpp: The Slick XMPP Library # Copyright (C) 2010 Nathanael C. Fritz # This file is part of Slixmpp. @@ -24,6 +23,7 @@ __all__ = [ 'xep_0049', # Private XML Storage 'xep_0050', # Ad-hoc Commands 'xep_0054', # vcard-temp + 'xep_0055', # Jabber Search 'xep_0059', # Result Set Management 'xep_0060', # Pubsub (Client) 'xep_0065', # SOCKS5 Bytestreams @@ -93,6 +93,7 @@ __all__ = [ 'xep_0335', # JSON Containers 'xep_0352', # Client State Indication 'xep_0353', # Jingle Message Initiation + 'xep_0356', # Privileged entity 'xep_0359', # Unique and Stable Stanza IDs 'xep_0363', # HTTP File Upload 'xep_0369', # MIX-CORE diff --git a/slixmpp/plugins/xep_0009/binding.py b/slixmpp/plugins/xep_0009/binding.py index ffec5431..4b64a010 100644 --- a/slixmpp/plugins/xep_0009/binding.py +++ b/slixmpp/plugins/xep_0009/binding.py @@ -22,7 +22,7 @@ def fault2xml(fault): def xml2fault(params): vals = [] - for value in params.xml.findall('{%s}value' % _namespace): + for value in params.findall('{%s}value' % _namespace): vals.append(_xml2py(value)) fault = dict() fault['code'] = vals[0]['faultCode'] diff --git a/slixmpp/plugins/xep_0009/rpc.py b/slixmpp/plugins/xep_0009/rpc.py index 497303a4..b00432b0 100644 --- a/slixmpp/plugins/xep_0009/rpc.py +++ b/slixmpp/plugins/xep_0009/rpc.py @@ -53,8 +53,8 @@ class XEP_0009(BasePlugin): def make_iq_method_call(self, pto, pmethod, params): iq = self.xmpp.make_iq_set() - iq.attrib['to'] = pto - iq.attrib['from'] = self.xmpp.boundjid.full + iq['to'] = pto + iq['from'] = self.xmpp.boundjid.full iq.enable('rpc_query') iq['rpc_query']['method_call']['method_name'] = pmethod iq['rpc_query']['method_call']['params'] = params @@ -62,16 +62,16 @@ class XEP_0009(BasePlugin): def make_iq_method_response(self, pid, pto, params): iq = self.xmpp.make_iq_result(pid) - iq.attrib['to'] = pto - iq.attrib['from'] = self.xmpp.boundjid.full + iq['to'] = pto + iq['from'] = self.xmpp.boundjid.full iq.enable('rpc_query') iq['rpc_query']['method_response']['params'] = params return iq def make_iq_method_response_fault(self, pid, pto, params): iq = self.xmpp.make_iq_result(pid) - iq.attrib['to'] = pto - iq.attrib['from'] = self.xmpp.boundjid.full + iq['to'] = pto + iq['from'] = self.xmpp.boundjid.full iq.enable('rpc_query') iq['rpc_query']['method_response']['params'] = None iq['rpc_query']['method_response']['fault'] = params diff --git a/slixmpp/plugins/xep_0030/disco.py b/slixmpp/plugins/xep_0030/disco.py index 0fa09927..1169a50e 100644 --- a/slixmpp/plugins/xep_0030/disco.py +++ b/slixmpp/plugins/xep_0030/disco.py @@ -326,7 +326,6 @@ class XEP_0030(BasePlugin): info_futures, _ = await asyncio.wait( infos, timeout=timeout, - loop=self.xmpp.loop ) self.domain_infos[domain] = [ @@ -386,6 +385,8 @@ class XEP_0030(BasePlugin): local = True ifrom = kwargs.pop('ifrom', None) + if self.xmpp.is_component and ifrom is None: + ifrom = self.xmpp.boundjid if local: log.debug("Looking up local disco#info data " "for %s, node %s.", jid, node) diff --git a/slixmpp/plugins/xep_0045/muc.py b/slixmpp/plugins/xep_0045/muc.py index 21f5c896..90cb73d7 100644 --- a/slixmpp/plugins/xep_0045/muc.py +++ b/slixmpp/plugins/xep_0045/muc.py @@ -35,6 +35,7 @@ from slixmpp.plugins.xep_0045 import stanza from slixmpp.plugins.xep_0045.stanza import ( MUCInvite, MUCDecline, + MUCDestroy, MUCPresence, MUCJoin, MUCMessage, @@ -55,6 +56,7 @@ from slixmpp.types import ( PresenceArgs, ) +JoinResult = Tuple[Presence, Message, List[Presence], List[Message]] log = logging.getLogger(__name__) @@ -70,7 +72,7 @@ class XEP_0045(BasePlugin): name = 'xep_0045' description = 'XEP-0045: Multi-User Chat' - dependencies = {'xep_0030', 'xep_0004'} + dependencies = {'xep_0030', 'xep_0004', 'xep_0203'} stanza = stanza rooms: Dict[JID, Dict[str, MucRoomItem]] @@ -88,6 +90,7 @@ class XEP_0045(BasePlugin): register_stanza_plugin(MUCMessage, MUCStatus) register_stanza_plugin(MUCPresence, MUCStatus) register_stanza_plugin(Presence, MUCPresence) + register_stanza_plugin(MUCPresence, MUCDestroy) register_stanza_plugin(Presence, MUCJoin) register_stanza_plugin(MUCJoin, MUCHistory) register_stanza_plugin(Message, MUCMessage) @@ -252,6 +255,7 @@ class XEP_0045(BasePlugin): if msg['body'] or msg['thread']: return self.xmpp.event('groupchat_subject', msg) + self.xmpp.event('muc::%s::groupchat_subject' % msg['from'].bare, msg) async def join_muc_wait(self, room: JID, nick: str, *, password: Optional[str] = None, @@ -260,7 +264,7 @@ class XEP_0045(BasePlugin): seconds: Optional[int] = None, since: Optional[datetime] = None, presence_options: Optional[PresenceArgs] = None, - timeout: Optional[int] = None) -> Presence: + timeout: Optional[int] = None) -> JoinResult: """ Try to join a MUC and block until we are joined or get an error. @@ -280,7 +284,8 @@ class XEP_0045(BasePlugin): presence error. :raises: An asyncio.TimeoutError if there is neither success nor presence error when the timeout is reached. - :return: Our own presence + :return: A tuple containing our own presence, the subject, a list + of occupants and a list of history messages. """ if presence_options is None: presence_options = {} @@ -303,23 +308,55 @@ class XEP_0045(BasePlugin): self.rooms[room] = {} self.our_nicks[room] = nick stanza.send() + return await self._await_join(room, timeout) - future: asyncio.Future = asyncio.Future() - context1 = self.xmpp.event_handler("muc::%s::self-presence" % room, future.set_result) - context2 = self.xmpp.event_handler("muc::%s::presence-error" % room, future.set_result) - with context1, context2: + async def _await_join(self, room: JID, timeout: Optional[int] = None) -> JoinResult: + """Do the heavy lifting for awaiting a MUC join + + A muc join, once the join stanza is sent, is: + occupant presences → self-presence → room history → room subject + """ + presence_done: asyncio.Future = asyncio.Future() + topic_received: asyncio.Future = asyncio.Future() + history_buffer: List[Message] = [] + occupant_buffer: List[Presence] = [] + + def add_message(msg: Message): + delay = msg.get_plugin('delay', check=True) + print(delay) + if delay is not None and delay['from'] == room: + history_buffer.append(msg) + + def add_occupant(pres: Presence): + occupant_buffer.append(pres) + + catch_occupants = self.xmpp.event_handler("muc::%s::got_online" % room, add_occupant) + catch_history = self.xmpp.event_handler("muc::%s::message" % room, add_message) + subject_handler = self.xmpp.event_handler("muc::%s::groupchat_subject" % room, topic_received.set_result) + self_presence = self.xmpp.event_handler("muc::%s::self-presence" % room, presence_done.set_result) + presence_error = self.xmpp.event_handler("muc::%s::presence-error" % room, presence_done.set_result) + + with subject_handler, catch_history, catch_occupants: + with self_presence, presence_error: + done, pending = await asyncio.wait( + [presence_done], + timeout=timeout, + ) + if pending: + raise asyncio.TimeoutError() + pres: Presence = presence_done.result() + if pres['type'] == 'error': + raise PresenceError(pres) done, pending = await asyncio.wait( - [future], + [topic_received], timeout=timeout, ) - if pending: - raise asyncio.TimeoutError() - pres = await future - if pres['type'] == 'error': - raise PresenceError(pres) + if pending: + raise asyncio.TimeoutError() + subject: Message = topic_received.result() # update known nick in case it has changed self.our_nicks[room] = pres['from'].resource - return pres + return (pres, subject, occupant_buffer, history_buffer) def join_muc(self, room: JID, nick: str, maxhistory="0", password='', pstatus='', pshow='', pfrom='') -> asyncio.Future: @@ -456,6 +493,8 @@ class XEP_0045(BasePlugin): """ if affiliation not in AFFILIATIONS: raise ValueError('%s is not a valid affiliation' % affiliation) + if affiliation == 'outcast' and not jid: + raise ValueError('Outcast affiliation requires a using a jid') if not any((jid, nick)): raise ValueError('One of jid or nick must be set') iq = self.xmpp.make_iq_set(ito=room, ifrom=ifrom) diff --git a/slixmpp/plugins/xep_0045/stanza.py b/slixmpp/plugins/xep_0045/stanza.py index 71eb3fb5..6c3d2244 100644 --- a/slixmpp/plugins/xep_0045/stanza.py +++ b/slixmpp/plugins/xep_0045/stanza.py @@ -179,6 +179,21 @@ class MUCInvite(ElementBase): interfaces = {'to', 'from', 'reason'} sub_interfaces = {'reason'} + def get_to(self) -> JID: + return JID(self._get_attr('to')) + + def set_to(self, value: Union[JID, str]): + if not isinstance(value, JID): + value = JID(value) + self._set_attr('to', value) + + def get_from(self) -> JID: + return JID(self._get_attr('from')) + + def set_from(self, value: Union[JID, str]): + if not isinstance(value, JID): + value = JID(value) + self._set_attr('from', value) class MUCDecline(ElementBase): name = 'decline' @@ -187,6 +202,22 @@ class MUCDecline(ElementBase): interfaces = {'to', 'from', 'reason'} sub_interfaces = {'reason'} + def get_to(self) -> JID: + return JID(self._get_attr('to')) + + def set_to(self, value: Union[JID, str]): + if not isinstance(value, JID): + value = JID(value) + self._set_attr('to', value) + + def get_from(self) -> JID: + return JID(self._get_attr('from')) + + def set_from(self, value: Union[JID, str]): + if not isinstance(value, JID): + value = JID(value) + self._set_attr('from', value) + class MUCHistory(ElementBase): name = 'history' @@ -204,6 +235,7 @@ class MUCOwnerQuery(ElementBase): class MUCOwnerDestroy(ElementBase): name = 'destroy' plugin_attrib = 'destroy' + namespace = NS_OWNER interfaces = {'reason', 'jid'} sub_interfaces = {'reason'} @@ -257,3 +289,11 @@ class MUCActor(ElementBase): if jid: return JID(jid) return jid + + +class MUCDestroy(ElementBase): + name = 'destroy' + plugin_attrib = 'destroy' + namespace = NS_USER + interfaces = {'reason', 'jid'} + sub_interfaces = {'reason'} diff --git a/slixmpp/plugins/xep_0047/stream.py b/slixmpp/plugins/xep_0047/stream.py index f020ea68..29e70707 100644 --- a/slixmpp/plugins/xep_0047/stream.py +++ b/slixmpp/plugins/xep_0047/stream.py @@ -99,6 +99,13 @@ class IBBytestream(object): :returns: All bytes accumulated in the stream. """ result = b'' + while not self.recv_queue.empty(): + result += self.recv_queue.get_nowait() + if max_data and len(result) > max_data: + return result + if self.stream_in_closed: + return result + end_future = asyncio.Future() def on_close(stream): @@ -115,7 +122,7 @@ class IBBytestream(object): self.xmpp.add_event_handler('ibb_stream_end', on_close) self.xmpp.add_event_handler('ibb_stream_data', on_data) try: - await asyncio.wait_for(end_future, timeout, loop=self.xmpp.loop) + await asyncio.wait_for(end_future, timeout) except asyncio.TimeoutError: raise IqTimeout(result) finally: diff --git a/slixmpp/plugins/xep_0055/__init__.py b/slixmpp/plugins/xep_0055/__init__.py new file mode 100644 index 00000000..981cf960 --- /dev/null +++ b/slixmpp/plugins/xep_0055/__init__.py @@ -0,0 +1,6 @@ +from slixmpp.plugins.base import register_plugin + +from .search import XEP_0055 + + +register_plugin(XEP_0055) diff --git a/slixmpp/plugins/xep_0055/search.py b/slixmpp/plugins/xep_0055/search.py new file mode 100644 index 00000000..a45b52a7 --- /dev/null +++ b/slixmpp/plugins/xep_0055/search.py @@ -0,0 +1,89 @@ +import logging + +from slixmpp import CoroutineCallback, StanzaPath, Iq, register_stanza_plugin +from slixmpp.plugins import BasePlugin +from slixmpp.xmlstream import StanzaBase + +from . import stanza + + +class XEP_0055(BasePlugin): + """ + XEP-0055: Jabber Search + + The config options are only useful for a "server-side" search feature, + and if the ``provide_search`` option is set to True. + + API + === + + ``search_get_form``: customize the search form content (ie fields) + + ``search_query``: return search results + """ + name = "xep_0055" + description = "XEP-0055: Jabber search" + dependencies = {"xep_0004", "xep_0030"} + stanza = stanza + default_config = { + "form_fields": {"first", "last"}, + "form_instructions": "", + "form_title": "", + "provide_search": True + } + + def plugin_init(self): + register_stanza_plugin(Iq, stanza.Search) + register_stanza_plugin(stanza.Search, self.xmpp["xep_0004"].stanza.Form) + + if self.provide_search: + self.xmpp["xep_0030"].add_feature(stanza.Search.namespace) + self.xmpp.register_handler( + CoroutineCallback( + "search", + StanzaPath("/iq/search"), + self._handle_search, + ) + ) + self.api.register(self._get_form, "search_get_form") + self.api.register(self._get_results, "search_query") + + async def _handle_search(self, iq: StanzaBase): + if iq["search"]["form"].get_values(): + reply = await self.api["search_query"](None, None, iq.get_from(), iq) + reply["search"]["form"]["type"] = "result" + else: + reply = await self.api["search_get_form"](None, None, iq.get_from(), iq) + reply["search"]["form"].add_field( + "FORM_TYPE", value=stanza.Search.namespace, ftype="hidden" + ) + reply.send() + + async def _get_form(self, jid, node, ifrom, iq): + reply = iq.reply() + form = reply["search"]["form"] + form["title"] = self.form_title + form["instructions"] = self.form_instructions + for field in self.form_fields: + form.add_field(field) + return reply + + async def _get_results(self, jid, node, ifrom, iq): + reply = iq.reply() + form = reply["search"]["form"] + form["type"] = "result" + + for field in self.form_fields: + form.add_reported(field) + return reply + + def make_search_iq(self, **kwargs): + iq = self.xmpp.make_iq(itype="set", **kwargs) + iq["search"]["form"].set_type("submit") + iq["search"]["form"].add_field( + "FORM_TYPE", value=stanza.Search.namespace, ftype="hidden" + ) + return iq + + +log = logging.getLogger(__name__) diff --git a/slixmpp/plugins/xep_0055/stanza.py b/slixmpp/plugins/xep_0055/stanza.py new file mode 100644 index 00000000..18bccf7e --- /dev/null +++ b/slixmpp/plugins/xep_0055/stanza.py @@ -0,0 +1,10 @@ +from typing import Set, ClassVar + +from slixmpp.xmlstream import ElementBase + + +class Search(ElementBase): + namespace = "jabber:iq:search" + name = "query" + plugin_attrib = "search" + interfaces: ClassVar[Set[str]] = set() diff --git a/slixmpp/plugins/xep_0082.py b/slixmpp/plugins/xep_0082.py index e8050286..0cdee465 100644 --- a/slixmpp/plugins/xep_0082.py +++ b/slixmpp/plugins/xep_0082.py @@ -6,7 +6,6 @@ import datetime as dt from slixmpp.plugins import BasePlugin, register_plugin -from slixmpp.thirdparty import tzutc, tzoffset, parse_iso # ===================================================================== @@ -21,7 +20,10 @@ def parse(time_str): Arguments: time_str -- A formatted timestamp string. """ - return parse_iso(time_str) + try: + return dt.datetime.strptime(time_str, '%Y-%m-%dT%H:%M:%S.%f%z') + except ValueError: + return dt.datetime.strptime(time_str, '%Y-%m-%dT%H:%M:%S%z') def format_date(time_obj): @@ -52,7 +54,7 @@ def format_time(time_obj): if isinstance(time_obj, dt.datetime): time_obj = time_obj.timetz() timestamp = time_obj.isoformat() - if time_obj.tzinfo == tzutc(): + if time_obj.tzinfo == dt.timezone.utc: timestamp = timestamp[:-6] return '%sZ' % timestamp return timestamp @@ -69,7 +71,7 @@ def format_datetime(time_obj): time_obj -- A datetime object. """ timestamp = time_obj.isoformat('T') - if time_obj.tzinfo == tzutc(): + if time_obj.tzinfo == dt.timezone.utc: timestamp = timestamp[:-6] return '%sZ' % timestamp return timestamp @@ -128,9 +130,9 @@ def time(hour=None, min=None, sec=None, micro=None, offset=None, obj=False): if micro is None: micro = now.microsecond if offset in (None, 0): - offset = tzutc() + offset = dt.timezone.utc elif not isinstance(offset, dt.tzinfo): - offset = tzoffset(None, offset) + offset = dt.timezone(dt.timedelta(seconds=offset)) value = dt.time(hour, min, sec, micro, offset) if obj: return value @@ -175,9 +177,9 @@ def datetime(year=None, month=None, day=None, hour=None, if micro is None: micro = now.microsecond if offset in (None, 0): - offset = tzutc() + offset = dt.timezone.utc elif not isinstance(offset, dt.tzinfo): - offset = tzoffset(None, offset) + offset = dt.timezone(dt.timedelta(seconds=offset)) value = dt.datetime(year, month, day, hour, min, sec, micro, offset) diff --git a/slixmpp/plugins/xep_0084/stanza.py b/slixmpp/plugins/xep_0084/stanza.py index a2132c0b..ed83526e 100644 --- a/slixmpp/plugins/xep_0084/stanza.py +++ b/slixmpp/plugins/xep_0084/stanza.py @@ -80,16 +80,16 @@ class Info(ElementBase): self._set_int('bytes', value) def get_height(self) -> int: - self._get_int('height') + return self._get_int('height') def set_height(self, value: int): self._set_int('height', value) def get_width(self) -> int: - self._get_int(self, 'width') + return self._get_int('width') def set_width(self, value: int): - self._set_int('with', value) + self._set_int('width', value) class Pointer(ElementBase): diff --git a/slixmpp/plugins/xep_0115/static.py b/slixmpp/plugins/xep_0115/static.py index 74f2beb8..4bf77d75 100644 --- a/slixmpp/plugins/xep_0115/static.py +++ b/slixmpp/plugins/xep_0115/static.py @@ -60,7 +60,7 @@ class StaticCaps(object): return False if node in (None, ''): - info = self.caps.get_caps(jid) + info = await self.caps.get_caps(jid) if info and feature in info['features']: return True @@ -134,7 +134,7 @@ class StaticCaps(object): def get_verstring(self, jid, node, ifrom, data): return self.jid_vers.get(jid, None) - def get_caps(self, jid, node, ifrom, data): + async def get_caps(self, jid, node, ifrom, data): verstring = data.get('verstring', None) if verstring is None: return None diff --git a/slixmpp/plugins/xep_0202/stanza.py b/slixmpp/plugins/xep_0202/stanza.py index 7d09de50..faa230f9 100644 --- a/slixmpp/plugins/xep_0202/stanza.py +++ b/slixmpp/plugins/xep_0202/stanza.py @@ -8,7 +8,6 @@ import datetime as dt from slixmpp.xmlstream import ElementBase from slixmpp.plugins import xep_0082 -from slixmpp.thirdparty import tzutc, tzoffset class EntityTime(ElementBase): @@ -87,7 +86,7 @@ class EntityTime(ElementBase): seconds (positive or negative) to offset. """ time = xep_0082.time(offset=value) - if xep_0082.parse(time).tzinfo == tzutc(): + if xep_0082.parse(time).tzinfo == dt.timezone.utc: self._set_sub_text('tzo', 'Z') else: self._set_sub_text('tzo', time[-6:]) @@ -111,6 +110,6 @@ class EntityTime(ElementBase): date = value if not isinstance(value, dt.datetime): date = xep_0082.parse(value) - date = date.astimezone(tzutc()) + date = date.astimezone(dt.timezone.utc) value = xep_0082.format_datetime(date) self._set_sub_text('utc', value) diff --git a/slixmpp/plugins/xep_0203/stanza.py b/slixmpp/plugins/xep_0203/stanza.py index f173d41c..a84cb52f 100644 --- a/slixmpp/plugins/xep_0203/stanza.py +++ b/slixmpp/plugins/xep_0203/stanza.py @@ -30,6 +30,10 @@ class Delay(ElementBase): def set_stamp(self, value): if isinstance(value, dt.datetime): + if value.tzinfo is None: + raise ValueError(f'Datetime provided without timezone information: {value}') + if value.tzinfo != dt.timezone.utc: + value = value.astimezone(dt.timezone.utc) value = xep_0082.format_datetime(value) self._set_attr('stamp', value) diff --git a/slixmpp/plugins/xep_0313/mam.py b/slixmpp/plugins/xep_0313/mam.py index 02efd3ce..f49ac637 100644 --- a/slixmpp/plugins/xep_0313/mam.py +++ b/slixmpp/plugins/xep_0313/mam.py @@ -149,6 +149,8 @@ class XEP_0313(BasePlugin): """ Iterate over each message of MAM query. + .. versionadded:: 1.8.0 + :param jid: Entity holding the MAM records :param start: MAM query start time :param end: MAM query end time @@ -239,7 +241,7 @@ class XEP_0313(BasePlugin): async def get_fields(self, jid: Optional[JID] = None, **iqkwargs) -> Form: """Get MAM query fields. - .. versionaddedd:: 1.8.0 + .. versionadded:: 1.8.0 :param jid: JID to retrieve the policy from. :return: The Form of allowed options diff --git a/slixmpp/plugins/xep_0333/markers.py b/slixmpp/plugins/xep_0333/markers.py index ca7a27da..86ddf612 100644 --- a/slixmpp/plugins/xep_0333/markers.py +++ b/slixmpp/plugins/xep_0333/markers.py @@ -1,4 +1,3 @@ - # slixmpp: The Slick XMPP Library # Copyright (C) 2016 Emmanuel Gil Peyrot # This file is part of slixmpp. @@ -68,11 +67,11 @@ class XEP_0333(BasePlugin): :param JID mto: recipient of the marker :param str id: Identifier of the marked message :param str marker: Marker to send (one of - displayed, retrieved, or acknowledged) + displayed, received, or acknowledged) :param str thread: Message thread :param str mfrom: Use a specific JID to send the message """ - if marker not in ('displayed', 'retrieved', 'acknowledged'): + if marker not in ('displayed', 'received', 'acknowledged'): raise ValueError('Invalid marker: %s' % marker) msg = self.xmpp.make_message(mto=mto, mfrom=mfrom) if thread: diff --git a/slixmpp/plugins/xep_0356/stanza.py b/slixmpp/plugins/xep_0356/stanza.py index ef01ee3e..46f1523a 100644 --- a/slixmpp/plugins/xep_0356/stanza.py +++ b/slixmpp/plugins/xep_0356/stanza.py @@ -7,7 +7,7 @@ from slixmpp.plugins.xep_0297 import Forwarded class Privilege(ElementBase): - namespace = "urn:xmpp:privilege:1" + namespace = "urn:xmpp:privilege:2" name = "privilege" plugin_attrib = "privilege" @@ -24,7 +24,10 @@ class Privilege(ElementBase): def presence(self): return self.permission("presence") - + + def iq(self): + return self.permission("iq") + def add_perm(self, access, type): # This should only be needed for servers, so maybe out of scope for slixmpp perm = Perm() @@ -34,7 +37,7 @@ class Privilege(ElementBase): class Perm(ElementBase): - namespace = "urn:xmpp:privilege:1" + namespace = "urn:xmpp:privilege:2" name = "perm" plugin_attrib = "perm" plugin_multi_attrib = "perms" @@ -44,4 +47,4 @@ class Perm(ElementBase): def register(): register_stanza_plugin(Message, Privilege) register_stanza_plugin(Privilege, Forwarded) - register_stanza_plugin(Privilege, Perm, iterable=True)
\ No newline at end of file + register_stanza_plugin(Privilege, Perm, iterable=True) diff --git a/slixmpp/plugins/xep_0363/http_upload.py b/slixmpp/plugins/xep_0363/http_upload.py index bae3ee7d..c34be8ff 100644 --- a/slixmpp/plugins/xep_0363/http_upload.py +++ b/slixmpp/plugins/xep_0363/http_upload.py @@ -14,6 +14,8 @@ from typing import ( IO, ) +from pathlib import Path + from slixmpp import JID, __version__ from slixmpp.stanza import Iq from slixmpp.plugins import BasePlugin @@ -99,12 +101,17 @@ class XEP_0363(BasePlugin): :param domain: Domain to disco to find a service. """ + if domain is None and self.xmpp.is_component: + domain = self.xmpp.server_host + results = await self.xmpp['xep_0030'].get_info_from_domain( domain=domain, **iqkwargs ) candidates = [] for info in results: + if not info['disco_info']: + continue for identity in info['disco_info']['identities']: if identity[0] == 'store' and identity[1] == 'file': candidates.append(info) @@ -113,7 +120,7 @@ class XEP_0363(BasePlugin): if feature == Request.namespace: return info - def request_slot(self, jid: JID, filename: str, size: int, + def request_slot(self, jid: JID, filename: Path, size: int, content_type: Optional[str] = None, *, ifrom: Optional[JID] = None, **iqkwargs) -> Future: """Request an HTTP upload slot from a service. @@ -125,12 +132,12 @@ class XEP_0363(BasePlugin): """ iq = self.xmpp.make_iq_get(ito=jid, ifrom=ifrom) request = iq['http_upload_request'] - request['filename'] = filename + request['filename'] = str(filename) request['size'] = str(size) request['content-type'] = content_type or self.default_content_type return iq.send(**iqkwargs) - async def upload_file(self, filename: str, size: Optional[int] = None, + async def upload_file(self, filename: Path, size: Optional[int] = None, content_type: Optional[str] = None, *, input_file: Optional[IO[bytes]]=None, domain: Optional[JID] = None, diff --git a/slixmpp/plugins/xep_0425/moderation.py b/slixmpp/plugins/xep_0425/moderation.py index 053e18f6..3c1308fc 100644 --- a/slixmpp/plugins/xep_0425/moderation.py +++ b/slixmpp/plugins/xep_0425/moderation.py @@ -44,4 +44,5 @@ class XEP_0425(BasePlugin): iq = self.xmpp.make_iq_set(ito=room.bare, ifrom=ifrom) iq['apply_to']['id'] = id iq['apply_to']['moderate']['reason'] = reason + iq['apply_to']['moderate'].enable('retract') await iq.send(**iqkwargs) diff --git a/slixmpp/plugins/xep_0444/stanza.py b/slixmpp/plugins/xep_0444/stanza.py index 02684df1..c9ee07d7 100644 --- a/slixmpp/plugins/xep_0444/stanza.py +++ b/slixmpp/plugins/xep_0444/stanza.py @@ -6,9 +6,7 @@ from typing import Set, Iterable from slixmpp.xmlstream import ElementBase try: - from emoji import UNICODE_EMOJI - if UNICODE_EMOJI.get('en'): - UNICODE_EMOJI = UNICODE_EMOJI['en'] + from emoji import EMOJI_DATA as UNICODE_EMOJI except ImportError: UNICODE_EMOJI = None diff --git a/slixmpp/plugins/xep_0454/__init__.py b/slixmpp/plugins/xep_0454/__init__.py new file mode 100644 index 00000000..d537432d --- /dev/null +++ b/slixmpp/plugins/xep_0454/__init__.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# vim:fenc=utf-8 et ts=4 sts=4 sw=4 +# +# Copyright © 2022 Maxime “pep” Buquet <pep@bouah.net> +# +# See the LICENSE file for copying permissions. + +""" + XEP-0454: OMEMO Media Sharing +""" + +from typing import IO, Optional, Tuple + +from os import urandom +from pathlib import Path +from io import BytesIO, SEEK_END + +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + +from slixmpp.plugins import BasePlugin +from slixmpp.plugins.base import register_plugin + + +class InvalidURL(Exception): + """Raised for URLs that either aren't HTTPS or already contain a fragment.""" + + +EXTENSIONS_MAP = { + 'jpeg': 'jpg', + 'text': 'txt', +} + +class XEP_0454(BasePlugin): + """ + XEP-0454: OMEMO Media Sharing + """ + + name = 'xep_0454' + description = 'XEP-0454: OMEMO Media Sharing' + dependencies = {'xep_0363'} + + @staticmethod + def encrypt(input_file: Optional[IO[bytes]] = None, filename: Optional[Path] = None) -> Tuple[bytes, str]: + """ + Encrypts file as specified in XEP-0454 for use in file sharing + + :param input_file: Binary file stream on the file. + :param filename: Path to the file to upload. + + One of input_file or filename must be specified. If both are + passed, input_file will be used and filename ignored. + """ + if input_file is None and filename is None: + raise ValueError('Specify either filename or input_file parameter') + + aes_gcm_iv = urandom(12) + aes_gcm_key = urandom(32) + + aes_gcm = Cipher( + algorithms.AES(aes_gcm_key), + modes.GCM(aes_gcm_iv), + ).encryptor() + + if input_file is None: + input_file = open(filename, 'rb') + + payload = b'' + while True: + buf = input_file.read(4096) + if not buf: + break + payload += aes_gcm.update(buf) + + payload += aes_gcm.finalize() + aes_gcm.tag + fragment = aes_gcm_iv.hex() + aes_gcm_key.hex() + return (payload, fragment) + + @staticmethod + def decrypt(input_file: IO[bytes], fragment: str) -> bytes: + """ + Decrypts file-like. + + :param input_file: Binary file stream on the file, containing the + tag (16 bytes) at the end. + :param fragment: 88 hex chars string composed of iv (24 chars) + + key (64 chars). + """ + + assert len(fragment) == 88 + aes_gcm_iv = bytes.fromhex(fragment[:24]) + aes_gcm_key = bytes.fromhex(fragment[24:]) + + # Find 16 bytes tag + input_file.seek(-16, SEEK_END) + tag = input_file.read() + + aes_gcm = Cipher( + algorithms.AES(aes_gcm_key), + modes.GCM(aes_gcm_iv, tag), + ).decryptor() + + size = input_file.seek(0, SEEK_END) + input_file.seek(0) + + count = size - 16 + plain = b'' + while count >= 0: + buf = input_file.read(4096) + count -= len(buf) + if count <= 0: + buf += input_file.read() + buf = buf[:-16] + plain += aes_gcm.update(buf) + plain += aes_gcm.finalize() + + return plain + + @staticmethod + def format_url(url: str, fragment: str) -> str: + """Helper to format a HTTPS URL to an AESGCM URI""" + if not url.startswith('https://') or url.find('#') != -1: + raise InvalidURL + return 'aesgcm://' + url[len('https://'):] + '#' + fragment + + @staticmethod + def map_extensions(ext: str) -> str: + """ + Apply conversions to extensions to reduce the number of + variations, (e.g., JPEG -> jpg). + """ + return EXTENSIONS_MAP.get(ext, ext).lower() + + async def upload_file( + self, + filename: Path, + _size: Optional[int] = None, + content_type: Optional[str] = None, + **kwargs, + ) -> str: + """ + Wrapper to xep_0363 (HTTP Upload)'s upload_file method. + + :param input_file: Binary file stream on the file. + :param filename: Path to the file to upload. + + Same as `XEP_0454.encrypt`, one of input_file or filename must be + specified. If both are passed, input_file will be used and + filename ignored. + + Other arguments passed in are passed to the actual + `XEP_0363.upload_file` call. + """ + input_file = kwargs.get('input_file') + payload, fragment = self.encrypt(input_file, filename) + + # Prepare kwargs for upload_file call + new_filename = urandom(12).hex() # Random filename to hide user-provided path + if filename.suffix: + new_filename += self.map_extensions(filename.suffix) + kwargs['filename'] = new_filename + + input_enc = BytesIO(payload) + kwargs['input_file'] = input_enc + + # Size must also be overriden if provided + size = input_enc.seek(0, SEEK_END) + input_enc.seek(0) + kwargs['size'] = size + + kwargs['content_type'] = content_type + + url = await self.xmpp['xep_0363'].upload_file(**kwargs) + return self.format_url(url, fragment) + +register_plugin(XEP_0454) diff --git a/slixmpp/plugins/xep_0461/__init__.py b/slixmpp/plugins/xep_0461/__init__.py new file mode 100644 index 00000000..1e9b2829 --- /dev/null +++ b/slixmpp/plugins/xep_0461/__init__.py @@ -0,0 +1,6 @@ +from slixmpp.plugins.base import register_plugin + +from .reply import XEP_0461 +from . import stanza + +register_plugin(XEP_0461) diff --git a/slixmpp/plugins/xep_0461/reply.py b/slixmpp/plugins/xep_0461/reply.py new file mode 100644 index 00000000..6607012a --- /dev/null +++ b/slixmpp/plugins/xep_0461/reply.py @@ -0,0 +1,48 @@ +from slixmpp.plugins import BasePlugin +from slixmpp.types import JidStr +from slixmpp.xmlstream import StanzaBase +from slixmpp.xmlstream.handler import Callback +from slixmpp.xmlstream.matcher import StanzaPath + +from . import stanza + + +class XEP_0461(BasePlugin): + """XEP-0461: Message Replies""" + + name = "xep_0461" + description = "XEP-0461: Message Replies" + + dependencies = {"xep_0030"} + stanza = stanza + namespace = stanza.NS + + def plugin_init(self) -> None: + stanza.register_plugins() + self.xmpp.register_handler( + Callback( + "Message replied to", + StanzaPath("message/reply"), + self._handle_reply_to_message, + ) + ) + + def plugin_end(self): + self.xmpp.plugin["xep_0030"].del_feature(feature=stanza.NS) + + def session_bind(self, jid): + self.xmpp.plugin["xep_0030"].add_feature(feature=stanza.NS) + + def _handle_reply_to_message(self, msg: StanzaBase): + self.xmpp.event("message_reply", msg) + + def send_reply(self, reply_to: JidStr, reply_id: str, **msg_kwargs): + """ + + :param reply_to: Full JID of the quoted author + :param reply_id: ID of the message to reply to + """ + msg = self.xmpp.make_message(**msg_kwargs) + msg["reply"]["to"] = reply_to + msg["reply"]["id"] = reply_id + msg.send() diff --git a/slixmpp/plugins/xep_0461/stanza.py b/slixmpp/plugins/xep_0461/stanza.py new file mode 100644 index 00000000..b99b2745 --- /dev/null +++ b/slixmpp/plugins/xep_0461/stanza.py @@ -0,0 +1,47 @@ +from slixmpp.stanza import Message +from slixmpp.xmlstream import ElementBase, register_stanza_plugin + +NS = "urn:xmpp:reply:0" + + +class Reply(ElementBase): + namespace = NS + name = "reply" + plugin_attrib = "reply" + interfaces = {"id", "to"} + + +class FeatureFallBack(ElementBase): + # should also be a multi attrib + namespace = "urn:xmpp:feature-fallback:0" + name = "fallback" + plugin_attrib = "feature_fallback" + interfaces = {"for"} + + def get_stripped_body(self): + # only works for a single fallback_body attrib + start = self["fallback_body"]["start"] + end = self["fallback_body"]["end"] + body = self.parent()["body"] + try: + start = int(start) + end = int(end) + except ValueError: + return body + else: + return body[:start] + body[end:] + + +class FallBackBody(ElementBase): + # According to https://xmpp.org/extensions/inbox/compatibility-fallback.html + # this should be a multi_attrib *but* since it's a protoXEP, we'll see... + namespace = FeatureFallBack.namespace + name = "body" + plugin_attrib = "fallback_body" + interfaces = {"start", "end"} + + +def register_plugins(): + register_stanza_plugin(Message, Reply) + register_stanza_plugin(Message, FeatureFallBack) + register_stanza_plugin(FeatureFallBack, FallBackBody) diff --git a/slixmpp/stanza/message.py b/slixmpp/stanza/message.py index 50d32ff0..eda11df6 100644 --- a/slixmpp/stanza/message.py +++ b/slixmpp/stanza/message.py @@ -64,9 +64,9 @@ class Message(RootStanza): if self.stream: use_ids = getattr(self.stream, 'use_message_ids', None) if use_ids: - self['id'] = self.stream.new_id() + self.set_id(self.stream.new_id()) else: - del self['origin_id'] + self.del_origin_id() def get_type(self): """ @@ -96,8 +96,8 @@ class Message(RootStanza): self.xml.attrib['id'] = value if self.stream: - use_orig_ids = getattr(self.stream, 'use_origin_id', None) - if not use_orig_ids: + if not getattr(self.stream, 'use_origin_id', False): + self.del_origin_id() return None sub = self.xml.find(ORIGIN_NAME) diff --git a/slixmpp/thirdparty/__init__.py b/slixmpp/thirdparty/__init__.py index d950f4f9..216a7b79 100644 --- a/slixmpp/thirdparty/__init__.py +++ b/slixmpp/thirdparty/__init__.py @@ -3,5 +3,4 @@ try: except: from slixmpp.thirdparty.gnupg import GPG -from slixmpp.thirdparty.mini_dateutil import tzutc, tzoffset, parse_iso from slixmpp.thirdparty.orderedset import OrderedSet diff --git a/slixmpp/thirdparty/mini_dateutil.py b/slixmpp/thirdparty/mini_dateutil.py deleted file mode 100644 index 882a531f..00000000 --- a/slixmpp/thirdparty/mini_dateutil.py +++ /dev/null @@ -1,273 +0,0 @@ -# This module is a very stripped down version of the dateutil -# package for when dateutil has not been installed. As a replacement -# for dateutil.parser.parse, the parsing methods from -# http://blog.mfabrik.com/2008/06/30/relativity-of-time-shortcomings-in-python-datetime-and-workaround/ - -#As such, the following copyrights and licenses applies: - - -# dateutil - Extensions to the standard python 2.3+ datetime module. -# -# Copyright (c) 2003-2011 - Gustavo Niemeyer <gustavo@niemeyer.net> -# -# All rights reserved. -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions are met: -# -# * Redistributions of source code must retain the above copyright notice, -# this list of conditions and the following disclaimer. -# * Redistributions in binary form must reproduce the above copyright notice, -# this list of conditions and the following disclaimer in the documentation -# and/or other materials provided with the distribution. -# * Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR -# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, -# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, -# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR -# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF -# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING -# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - - -# fixed_dateime -# -# Copyright (c) 2008, Red Innovation Ltd., Finland -# All rights reserved. -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions are met: -# * Redistributions of source code must retain the above copyright -# notice, this list of conditions and the following disclaimer. -# * Redistributions in binary form must reproduce the above copyright -# notice, this list of conditions and the following disclaimer in the -# documentation and/or other materials provided with the distribution. -# * Neither the name of Red Innovation nor the names of its contributors -# may be used to endorse or promote products derived from this software -# without specific prior written permission. -# -# THIS SOFTWARE IS PROVIDED BY RED INNOVATION ``AS IS'' AND ANY -# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -# DISCLAIMED. IN NO EVENT SHALL RED INNOVATION BE LIABLE FOR ANY -# DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND -# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - - - -import re -import math -import datetime - - -ZERO = datetime.timedelta(0) - - -try: - from dateutil.parser import parse as parse_iso - from dateutil.tz import tzoffset, tzutc -except: - # As a stopgap, define the two timezones here based - # on the dateutil code. - - class tzutc(datetime.tzinfo): - - def utcoffset(self, dt): - return ZERO - - def dst(self, dt): - return ZERO - - def tzname(self, dt): - return "UTC" - - def __eq__(self, other): - return (isinstance(other, tzutc) or - (isinstance(other, tzoffset) and other._offset == ZERO)) - - def __ne__(self, other): - return not self.__eq__(other) - - def __repr__(self): - return "%s()" % self.__class__.__name__ - - __reduce__ = object.__reduce__ - - class tzoffset(datetime.tzinfo): - - def __init__(self, name, offset): - self._name = name - self._offset = datetime.timedelta(minutes=offset) - - def utcoffset(self, dt): - return self._offset - - def dst(self, dt): - return ZERO - - def tzname(self, dt): - return self._name - - def __eq__(self, other): - return (isinstance(other, tzoffset) and - self._offset == other._offset) - - def __ne__(self, other): - return not self.__eq__(other) - - def __repr__(self): - return "%s(%s, %s)" % (self.__class__.__name__, - repr(self._name), - self._offset.days*86400+self._offset.seconds) - - __reduce__ = object.__reduce__ - - - _fixed_offset_tzs = { } - UTC = tzutc() - - def _get_fixed_offset_tz(offsetmins): - """For internal use only: Returns a tzinfo with - the given fixed offset. This creates only one instance - for each offset; the zones are kept in a dictionary""" - - if offsetmins == 0: - return UTC - - if not offsetmins in _fixed_offset_tzs: - if offsetmins < 0: - sign = '-' - absoff = -offsetmins - else: - sign = '+' - absoff = offsetmins - - name = "UTC%s%02d:%02d" % (sign, int(absoff / 60), absoff % 60) - inst = tzoffset(name,offsetmins) - _fixed_offset_tzs[offsetmins] = inst - - return _fixed_offset_tzs[offsetmins] - - - _iso8601_parser = re.compile(r""" - ^ - (?P<year> [0-9]{4})?(?P<ymdsep>-?)? - (?P<month>[0-9]{2})?(?P=ymdsep)? - (?P<day> [0-9]{2})? - - (?P<time> - (?: # time part... optional... at least hour must be specified - (?:T|\s+)? - (?P<hour>[0-9]{2}) - (?: - # minutes, separated with :, or none, from hours - (?P<hmssep>[:]?) - (?P<minute>[0-9]{2}) - (?: - # same for seconds, separated with :, or none, from hours - (?P=hmssep) - (?P<second>[0-9]{2}) - )? - )? - - # fractions - (?: [,.] (?P<frac>[0-9]{1,10}))? - - # timezone, Z, +-hh or +-hh:?mm. MUST BE, but complain if not there. - ( - (?P<tzempty>Z) - | - (?P<tzh>[+-][0-9]{2}) - (?: :? # optional separator - (?P<tzm>[0-9]{2}) - )? - )? - ) - )? - $ - """, re.X) # """ - - def parse_iso(timestamp): - """Internal function for parsing a timestamp in - ISO 8601 format""" - - timestamp = timestamp.strip() - - m = _iso8601_parser.match(timestamp) - if not m: - raise ValueError("Not a proper ISO 8601 timestamp!: %s" % timestamp) - - vals = m.groupdict() - def_vals = {'year': 1970, 'month': 1, 'day': 1} - for key in vals: - if vals[key] is None: - vals[key] = def_vals.get(key, 0) - elif key not in ['time', 'ymdsep', 'hmssep', 'tzempty']: - vals[key] = int(vals[key]) - - year = vals['year'] - month = vals['month'] - day = vals['day'] - - if m.group('time') is None: - return datetime.date(year, month, day) - - h, min, s, us = None, None, None, 0 - frac = 0 - if m.group('tzempty') == None and m.group('tzh') == None: - raise ValueError("Not a proper ISO 8601 timestamp: " + - "missing timezone (Z or +hh[:mm])!") - - if m.group('frac'): - frac = m.group('frac') - power = len(frac) - frac = int(frac) / 10.0 ** power - - if m.group('hour'): - h = vals['hour'] - - if m.group('minute'): - min = vals['minute'] - - if m.group('second'): - s = vals['second'] - - if frac != None: - # ok, fractions of hour? - if min == None: - frac, min = math.modf(frac * 60.0) - min = int(min) - - # fractions of second? - if s == None: - frac, s = math.modf(frac * 60.0) - s = int(s) - - # and extract microseconds... - us = int(frac * 1000000) - - if m.group('tzempty') == 'Z': - offsetmins = 0 - else: - # timezone: hour diff with sign - offsetmins = vals['tzh'] * 60 - tzm = m.group('tzm') - - # add optional minutes - if tzm != None: - tzm = int(tzm) - offsetmins += tzm if offsetmins > 0 else -tzm - - tz = _get_fixed_offset_tz(offsetmins) - return datetime.datetime(year, month, day, h, min, s, us, tz) diff --git a/slixmpp/version.py b/slixmpp/version.py index 7cff2e74..bf67f729 100644 --- a/slixmpp/version.py +++ b/slixmpp/version.py @@ -1,4 +1,3 @@ - # Slixmpp: The Slick XMPP Library # Copyright (C) 2010 Nathanael C. Fritz # This file is part of Slixmpp. @@ -6,5 +5,5 @@ # We don't want to have to import the entire library # just to get the version info for setup.py -__version__ = '1.7.0' -__version_info__ = (1, 7, 0) +__version__ = '1.8.2' +__version_info__ = (1, 8, 2) diff --git a/slixmpp/xmlstream/handler/waiter.py b/slixmpp/xmlstream/handler/waiter.py index dde49754..599004b5 100644 --- a/slixmpp/xmlstream/handler/waiter.py +++ b/slixmpp/xmlstream/handler/waiter.py @@ -80,7 +80,7 @@ class Waiter(BaseHandler): try: await wait_for( - self._event.wait(), timeout, loop=stream.loop + self._event.wait(), timeout, ) except TimeoutError: log.warning("Timed out waiting for %s", self.name) diff --git a/slixmpp/xmlstream/resolver.py b/slixmpp/xmlstream/resolver.py index e524da3b..3de6629d 100644 --- a/slixmpp/xmlstream/resolver.py +++ b/slixmpp/xmlstream/resolver.py @@ -15,7 +15,13 @@ from slixmpp.types import Protocol log = logging.getLogger(__name__) -class AnswerProtocol(Protocol): +class GetHostByNameAnswerProtocol(Protocol): + name: str + aliases: List[str] + addresses: List[str] + + +class QueryAnswerProtocol(Protocol): host: str priority: int weight: int @@ -23,6 +29,9 @@ class AnswerProtocol(Protocol): class ResolverProtocol(Protocol): + def gethostbyname(self, host: str, socket_family: socket.AddressFamily) -> Future: + ... + def query(self, query: str, querytype: str) -> Future: ... @@ -147,11 +156,6 @@ async def resolve(host: str, port: int, *, loop: AbstractEventLoop, results = [] for host, port in hosts: - if host == 'localhost': - if use_ipv6: - results.append((host, '::1', port)) - results.append((host, '127.0.0.1', port)) - if use_ipv6: aaaa = await get_AAAA(host, resolver=resolver, use_aiodns=use_aiodns, loop=loop) @@ -201,13 +205,13 @@ async def get_A(host: str, *, loop: AbstractEventLoop, return [] # Using aiodns: - future = resolver.query(host, 'A') + future = resolver.gethostbyname(host, socket.AF_INET) try: - recs = cast(Iterable[AnswerProtocol], await future) + recs = cast(GetHostByNameAnswerProtocol, await future) except Exception as e: log.debug('DNS: Exception while querying for %s A records: %s', host, e) - recs = [] - return [rec.host for rec in recs] + return [] + return [addr for addr in recs.addresses] async def get_AAAA(host: str, *, loop: AbstractEventLoop, @@ -249,13 +253,13 @@ async def get_AAAA(host: str, *, loop: AbstractEventLoop, return [] # Using aiodns: - future = resolver.query(host, 'AAAA') + future = resolver.gethostbyname(host, socket.AF_INET6) try: - recs = cast(Iterable[AnswerProtocol], await future) + recs = cast(GetHostByNameAnswerProtocol, await future) except Exception as e: log.debug('DNS: Exception while querying for %s AAAA records: %s', host, e) - recs = [] - return [rec.host for rec in recs] + return [] + return [addr for addr in recs.addresses] async def get_SRV(host: str, port: int, service: str, @@ -295,12 +299,12 @@ async def get_SRV(host: str, port: int, service: str, try: future = resolver.query('_%s._%s.%s' % (service, proto, host), 'SRV') - recs = cast(Iterable[AnswerProtocol], await future) + recs = cast(Iterable[QueryAnswerProtocol], await future) except Exception as e: log.debug('DNS: Exception while querying for %s SRV records: %s', host, e) return [] - answers: Dict[int, List[AnswerProtocol]] = {} + answers: Dict[int, List[QueryAnswerProtocol]] = {} for rec in recs: if rec.priority not in answers: answers[rec.priority] = [] diff --git a/slixmpp/xmlstream/xmlstream.py b/slixmpp/xmlstream/xmlstream.py index 30f99071..19c4ddcc 100644 --- a/slixmpp/xmlstream/xmlstream.py +++ b/slixmpp/xmlstream/xmlstream.py @@ -15,6 +15,7 @@ from typing import ( Coroutine, Callable, Iterator, + Iterable, List, Optional, Set, @@ -31,8 +32,10 @@ import functools import logging import socket as Socket import ssl -import weakref import uuid +import warnings +import weakref +import collections from contextlib import contextmanager import xml.etree.ElementTree as ET @@ -46,6 +49,7 @@ from asyncio import ( iscoroutinefunction, wait, ) +from pathlib import Path from slixmpp.types import FilterString from slixmpp.xmlstream.tostring import tostring @@ -74,6 +78,15 @@ class NotConnectedError(Exception): """ +class InvalidCABundle(Exception): + """ + Exception raised when the CA Bundle file hasn't been found. + """ + + def __init__(self, path: Optional[Union[Path, Iterable[Path]]]): + self.path = path + + _T = TypeVar('_T', str, ElementBase, StanzaBase) @@ -161,7 +174,7 @@ class XMLStream(asyncio.BaseProtocol): #: #: On Mac OS X, certificates in the system keyring will #: be consulted, even if they are not in the provided file. - ca_certs: Optional[str] + ca_certs: Optional[Union[Path, Iterable[Path]]] #: Path to a file containing a client certificate to use for #: authenticating via SASL EXTERNAL. If set, there must also @@ -449,7 +462,7 @@ class XMLStream(asyncio.BaseProtocol): if self._connect_loop_wait > 0: self.event('reconnect_delay', self._connect_loop_wait) - await asyncio.sleep(self._connect_loop_wait, loop=self.loop) + await asyncio.sleep(self._connect_loop_wait) record = await self._pick_dns_answer(self.default_domain) if record is not None: @@ -480,16 +493,11 @@ class XMLStream(asyncio.BaseProtocol): except Socket.gaierror as e: self.event('connection_failed', 'No DNS record available for %s' % self.default_domain) + self.reschedule_connection_attempt() except OSError as e: log.debug('Connection failed: %s', e) self.event("connection_failed", e) - if self._current_connection_attempt is None: - return - self._connect_loop_wait = self._connect_loop_wait * 2 + 1 - self._current_connection_attempt = asyncio.ensure_future( - self._connect_routine(), - loop=self.loop, - ) + self.reschedule_connection_attempt() def process(self, *, forever: bool = True, timeout: Optional[int] = None) -> None: """Process all the available XMPP events (receiving or sending data on the @@ -497,17 +505,27 @@ class XMLStream(asyncio.BaseProtocol): timers, handling signal events, etc). If timeout is None, this function will run forever. If timeout is a number, this function will return after the given time in seconds. + + Will be removed in slixmpp 1.9.0 + + :deprecated: 1.8.0 """ + warnings.warn( + 'This function will be removed in slixmpp 1.9 and above.' + ' Use the asyncio normal functions instead.', + category=DeprecationWarning, + stacklevel=2, + ) if timeout is None: if forever: self.loop.run_forever() else: self.loop.run_until_complete(self.disconnected) else: - tasks: List[Future] = [asyncio.sleep(timeout, loop=self.loop)] + tasks: List[Awaitable] = [asyncio.sleep(timeout)] if not forever: tasks.append(self.disconnected) - self.loop.run_until_complete(asyncio.wait(tasks, loop=self.loop)) + self.loop.run_until_complete(asyncio.wait(tasks)) def init_parser(self) -> None: """init the XML parser. The parser must always be reset for each new @@ -556,7 +574,7 @@ class XMLStream(asyncio.BaseProtocol): stream=self, top_level=True, open_only=True)) - self.start_stream_handler(self.xml_root) + self.start_stream_handler(self.xml_root) # type:ignore self.xml_depth += 1 if event == 'end': self.xml_depth -= 1 @@ -615,6 +633,20 @@ class XMLStream(asyncio.BaseProtocol): self._set_disconnected_future() self.event("disconnected", self.disconnect_reason or exception) + def reschedule_connection_attempt(self) -> None: + """ + Increase the exponential back-off and initate another background + _connect_routine call to connect to the server. + """ + # abort if there is no ongoing connection attempt + if self._current_connection_attempt is None: + return + self._connect_loop_wait = min(300, self._connect_loop_wait * 2 + 1) + self._current_connection_attempt = asyncio.ensure_future( + self._connect_routine(), + loop=self.loop, + ) + def cancel_connection_attempt(self) -> None: """ Immediately cancel the current create_connection() Future. @@ -715,7 +747,7 @@ class XMLStream(asyncio.BaseProtocol): log.debug("reconnecting...") async def handler(event: Any) -> None: # We yield here to allow synchronous handlers to work first - await asyncio.sleep(0, loop=self.loop) + await asyncio.sleep(0) self.connect() self.add_event_handler('disconnected', handler, disposable=True) self.disconnect(wait, reason) @@ -759,8 +791,26 @@ class XMLStream(asyncio.BaseProtocol): log.debug('Loaded cert file %s and key file %s', self.certfile, self.keyfile) if self.ca_certs is not None: + ca_cert: Optional[Path] = None + # XXX: Compat before d733c54518. + if isinstance(self.ca_certs, str): + self.ca_certs = Path(self.ca_certs) + if isinstance(self.ca_certs, Path): + if self.ca_certs.is_file(): + ca_cert = self.ca_certs + else: + for bundle in self.ca_certs: + if bundle.is_file(): + ca_cert = bundle + break + if ca_cert is None and \ + isinstance(self.ca_certs, (Path, collections.abc.Iterable)): + raise InvalidCABundle(self.ca_certs) + self.ssl_context.verify_mode = ssl.CERT_REQUIRED - self.ssl_context.load_verify_locations(cafile=self.ca_certs) + self.ssl_context.load_verify_locations(cafile=ca_cert) + else: + self.ssl_context.set_default_verify_paths() return self.ssl_context @@ -1202,7 +1252,7 @@ class XMLStream(asyncio.BaseProtocol): else: self.send_raw(data) - async def run_filters(self) -> NoReturn: + async def run_filters(self) -> None: """ Background loop that processes stanzas to send. """ @@ -1217,7 +1267,7 @@ class XMLStream(asyncio.BaseProtocol): already_run_filters.add(filter) if iscoroutinefunction(filter): filter = cast(AsyncFilter, filter) - task = asyncio.create_task(filter(data)) + task = asyncio.create_task(filter(data)) # type:ignore completed, pending = await wait( {task}, timeout=1, @@ -1258,6 +1308,9 @@ class XMLStream(asyncio.BaseProtocol): self.send_raw(data) except ContinueQueue as exc: log.debug('Stanza in send queue not sent: %s', exc) + except asyncio.CancelledError: + log.debug('Send coroutine received cancel(), stopping') + return except Exception: log.error('Exception raised in send queue:', exc_info=True) self.waiting_queue.task_done() @@ -1278,10 +1331,16 @@ class XMLStream(asyncio.BaseProtocol): # Avoid circular imports from slixmpp.stanza.rootstanza import RootStanza from slixmpp.stanza import Iq, Handshake - passthrough = ( - (isinstance(data, Iq) and data.get_plugin('bind', check=True)) - or isinstance(data, Handshake) - ) + + passthrough = False + if isinstance(data, Iq): + if data.get_plugin('bind', check=True): + passthrough = True + elif data.get_plugin('session', check=True): + passthrough = True + elif isinstance(data, Handshake): + passthrough = True + if isinstance(data, (RootStanza, str)) and not passthrough: self.__queued_stanzas.append((data, use_filters)) log.debug('NOT SENT: %s %s', type(data), data) diff --git a/tests/test_stanza_xep_0055.py b/tests/test_stanza_xep_0055.py new file mode 100644 index 00000000..9ff45efa --- /dev/null +++ b/tests/test_stanza_xep_0055.py @@ -0,0 +1,59 @@ +import unittest + +from slixmpp import register_stanza_plugin, Iq +from slixmpp.test import SlixTest + +from slixmpp.plugins.xep_0055 import stanza + + +class TestJabberSearch(SlixTest): + def setUp(self): + register_stanza_plugin(Iq, stanza.Search) + self.stream_start(plugins={"xep_0055"}) + + def testRequestSearchFields(self): + iq = self.Iq() + iq.set_from("juliet@capulet.com/balcony") + iq.set_to("characters.shakespeare.lit") + iq.set_type("get") + iq.enable("search") + iq["id"] = "0" + self.check( + iq, + """ + <iq type='get' + from='juliet@capulet.com/balcony' + to='characters.shakespeare.lit'> + <query xmlns='jabber:iq:search'/> + </iq> + """, + ) + + def testSendSearch(self): + iq = self.xmpp["xep_0055"].make_search_iq( + ifrom="juliet@capulet.com/balcony", ito="characters.shakespeare.lit" + ) + iq["search"]["form"].add_field(var="x-gender", value="male") + self.check( + iq, + """ + <iq type='set' + from='juliet@capulet.com/balcony' + to='characters.shakespeare.lit'> + <query xmlns='jabber:iq:search'> + <x xmlns='jabber:x:data' type='submit'> + <field type='hidden' var='FORM_TYPE'> + <value>jabber:iq:search</value> + </field> + <field var='x-gender'> + <value>male</value> + </field> + </x> + </query> + </iq> + """, + use_values=False, + ) + + +suite = unittest.TestLoader().loadTestsFromTestCase(TestJabberSearch) diff --git a/tests/test_stanza_xep_0356.py b/tests/test_stanza_xep_0356.py index ef116db2..cf14ccba 100644 --- a/tests/test_stanza_xep_0356.py +++ b/tests/test_stanza_xep_0356.py @@ -13,7 +13,7 @@ class TestPermissions(SlixTest): def testAdvertisePermission(self): xmlstring = """ <message from='capulet.net' to='pubub.capulet.lit'> - <privilege xmlns='urn:xmpp:privilege:1'> + <privilege xmlns='urn:xmpp:privilege:2'> <perm access='roster' type='both'/> <perm access='message' type='outgoing'/> <perm access='presence' type='managed_entity'/> diff --git a/tests/test_stanza_xep_0444.py b/tests/test_stanza_xep_0444.py index 0fe3f6fc..48d569e3 100644 --- a/tests/test_stanza_xep_0444.py +++ b/tests/test_stanza_xep_0444.py @@ -23,7 +23,7 @@ class TestReactions(SlixTest): def setUp(self): register_stanza_plugin(Message, stanza.Reactions) - register_stanza_plugin(stanza.Reactions, stanza.Reaction) + register_stanza_plugin(stanza.Reactions, stanza.Reaction, iterable=True) def testCreateReactions(self): """Testing creating Reactions.""" diff --git a/tests/test_stanza_xep_0461.py b/tests/test_stanza_xep_0461.py new file mode 100644 index 00000000..b9550481 --- /dev/null +++ b/tests/test_stanza_xep_0461.py @@ -0,0 +1,48 @@ +import unittest +from slixmpp import Message +from slixmpp.test import SlixTest +from slixmpp.plugins.xep_0461 import stanza + + +class TestReply(SlixTest): + def setUp(self): + stanza.register_plugins() + + def testReply(self): + message = Message() + message["reply"]["id"] = "some-id" + message["body"] = "some-body" + + self.check( + message, + """ + <message> + <reply xmlns="urn:xmpp:reply:0" id="some-id" /> + <body>some-body</body> + </message> + """, + ) + + def testFallback(self): + message = Message() + message["body"] = "12345\nrealbody" + message["feature_fallback"]["for"] = "NS" + message["feature_fallback"]["fallback_body"]["start"] = "0" + message["feature_fallback"]["fallback_body"]["end"] = "6" + + self.check( + message, + """ + <message xmlns="jabber:client"> + <body>12345\nrealbody</body> + <fallback xmlns='urn:xmpp:feature-fallback:0' for='NS'> + <body start="0" end="6" /> + </fallback> + </message> + """, + ) + + assert message["feature_fallback"].get_stripped_body() == "realbody" + + +suite = unittest.TestLoader().loadTestsFromTestCase(TestReply) diff --git a/tests/test_stream_xep_0055.py b/tests/test_stream_xep_0055.py new file mode 100644 index 00000000..fa028d8b --- /dev/null +++ b/tests/test_stream_xep_0055.py @@ -0,0 +1,170 @@ +import unittest +from slixmpp.test import SlixTest + + +class TestJabberSearch(SlixTest): + def setUp(self): + self.stream_start( + mode="component", + plugin_config={ + "xep_0055": { + "form_fields": {"first", "last"}, + "form_instructions": "INSTRUCTIONS", + "form_title": "User Directory Search", + } + }, + jid="characters.shakespeare.lit", + plugins={"xep_0055"} + ) + self.xmpp["xep_0055"].api.register(get_results, "search_query") + self.xmpp["xep_0055"].api.register(get_results, "search_query") + + def tearDown(self): + self.stream_close() + + def testRequestingSearchFields(self): + self.recv( + """ + <iq type='get' + from='juliet@capulet.com/balcony' + to='characters.shakespeare.lit' + id='search3' + xml:lang='en'> + <query xmlns='jabber:iq:search'/> + </iq> + """ + ) + self.send( + """ + <iq type='result' + from='characters.shakespeare.lit' + to='juliet@capulet.com/balcony' + id='search3' + xml:lang='en'> + <query xmlns='jabber:iq:search'> + <x xmlns='jabber:x:data' type='form'> + <title>User Directory Search</title> + <instructions>INSTRUCTIONS</instructions> + <field type='hidden' + var='FORM_TYPE'> + <value>jabber:iq:search</value> + </field> + <field var='first'/> + <field var='last'/> + </x> + </query> + </iq> + """, + use_values=False, + ) + + def testSearchResult(self): + self.recv( + """ + <iq type='get' + from='juliet@capulet.com/balcony' + to='characters.shakespeare.lit' + id='search2' + xml:lang='en'> + <query xmlns='jabber:iq:search'> + <x xmlns='jabber:x:data' type='submit'> + <field type='hidden' var='FORM_TYPE'> + <value>jabber:iq:search</value> + </field> + <field var='last'> + <value>Montague</value> + </field> + </x> + </query> + </iq> + """ + ) + self.send( + """ + <iq type='result' + from='characters.shakespeare.lit' + to='juliet@capulet.com/balcony' + id='search2' + xml:lang='en'> + <query xmlns='jabber:iq:search'> + <x xmlns='jabber:x:data' type='result'> + <field type='hidden' var='FORM_TYPE'> + <value>jabber:iq:search</value> + </field> + <reported> + <field var='first' label='Given Name' /> + <field var='last' label='Family Name' /> + </reported> + <item> + <field var='first'><value>Benvolio</value></field> + <field var='last'><value>Montague</value></field> + </item> + </x> + </query> + </iq> + """, + use_values=False, # TypeError: element indices must be integers without that + ) + + def testSearchNoResult(self): + self.xmpp["xep_0055"].api.register(get_results, "search_query") + self.recv( + """ + <iq type='get' + from='juliet@capulet.com/balcony' + to='characters.shakespeare.lit' + id='search2' + xml:lang='en'> + <query xmlns='jabber:iq:search'> + <x xmlns='jabber:x:data' type='submit'> + <field type='hidden' var='FORM_TYPE'> + <value>jabber:iq:search</value> + </field> + <field var='last'> + <value>Capulet</value> + </field> + </x> + </query> + </iq> + """ + ) + self.send( + """ + <iq type='result' + from='characters.shakespeare.lit' + to='juliet@capulet.com/balcony' + id='search2' + xml:lang='en'> + <query xmlns='jabber:iq:search'> + <x xmlns='jabber:x:data' type='result'> + <field type='hidden' var='FORM_TYPE'> + <value>jabber:iq:search</value> + </field> + <reported> + <field var='first' label='Given Name' /> + <field var='last' label='Family Name' /> + </reported> + </x> + </query> + </iq> + """, + use_values=False, # TypeError: element indices must be integers without that + ) + +async def get_results(jid, node, ifrom, iq): + reply = iq.reply() + form = reply["search"]["form"] + form["type"] = "result" + + form.add_reported("first", label="Given Name") + form.add_reported("last", label="Family Name") + + d = iq["search"]["form"].get_values() + + if d["last"] == "Montague": + form.add_item({"first": "Benvolio", "last": "Montague"}) + + return reply + + +suite = unittest.TestLoader().loadTestsFromTestCase(TestJabberSearch) diff --git a/tests/test_stream_xep_0356.py b/tests/test_stream_xep_0356.py index 2949daad..e2ce9569 100644 --- a/tests/test_stream_xep_0356.py +++ b/tests/test_stream_xep_0356.py @@ -31,7 +31,7 @@ class TestPermissions(SlixTest): self.recv( """ <message from='capulet.net' to='pubub.capulet.lit' id='54321'> - <privilege xmlns='urn:xmpp:privilege:1'> + <privilege xmlns='urn:xmpp:privilege:2'> <perm access='roster' type='both'/> <perm access='message' type='outgoing'/> </privilege> @@ -95,7 +95,7 @@ class TestPermissions(SlixTest): def testMakeOutgoingMessage(self): xmlstring = """ <message xmlns="jabber:component:accept" from='pubsub.capulet.lit' to='capulet.net'> - <privilege xmlns='urn:xmpp:privilege:1'> + <privilege xmlns='urn:xmpp:privilege:2'> <forwarded xmlns='urn:xmpp:forward:0'> <message from="juliet@capulet.lit" to="romeo@montague.lit" xmlns="jabber:client"> <body>I do not hate you</body> diff --git a/tests/test_stream_xep_0461.py b/tests/test_stream_xep_0461.py new file mode 100644 index 00000000..b73a9964 --- /dev/null +++ b/tests/test_stream_xep_0461.py @@ -0,0 +1,48 @@ +import logging +import unittest +from slixmpp.test import SlixTest + + +class TestReply(SlixTest): + def setUp(self): + self.stream_start(plugins=["xep_0461"]) + + def tearDown(self): + self.stream_close() + + def testFallBackBody(self): + async def on_reply(msg): + start = msg["feature_fallback"]["fallback_body"]["start"] + end = msg["feature_fallback"]["fallback_body"]["end"] + self.xmpp["xep_0461"].send_reply( + reply_to=msg.get_from(), + reply_id=msg.get_id(), + mto="test@test.com", + mbody=f"{start} to {end}", + ) + + self.xmpp.add_event_handler("message_reply", on_reply) + + self.recv( + """ + <message id="other-id" from="from@from.com/res"> + <reply xmlns="urn:xmpp:reply:0" id="some-id" /> + <body>> quoted\nsome-body</body> + <fallback xmlns='urn:xmpp:feature-fallback:0' for='urn:xmpp:reply:0'> + <body start="0" end="8" /> + </fallback> + </message> + """ + ) + self.send( + """ + <message xmlns="jabber:client" to="test@test.com" type="normal"> + <reply xmlns="urn:xmpp:reply:0" id="other-id" to="from@from.com/res" /> + <body>0 to 8</body> + </message> + """ + ) + + +logging.basicConfig(level=logging.DEBUG) +suite = unittest.TestLoader().loadTestsFromTestCase(TestReply) diff --git a/tests/test_xep_0454.py b/tests/test_xep_0454.py new file mode 100644 index 00000000..8d43c7e3 --- /dev/null +++ b/tests/test_xep_0454.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# vim:fenc=utf-8 et ts=4 sts=4 sw=4 +# +# Copyright © 2022 Maxime “pep” Buquet <pep@bouah.net> +# +# Distributed under terms of the GPLv3+ license. + +""" + Tests for XEP-0454 (OMEMO Media Sharing) plugin. +""" + +import unittest +from io import BytesIO +from slixmpp.test import SlixTest +from slixmpp.plugins.xep_0454 import XEP_0454 + + +class TestMediaSharing(SlixTest): + + def testEncryptDecryptSmall(self): + plain = b'qwertyuiop' + ciphertext, fragment = XEP_0454.encrypt(input_file=BytesIO(plain)) + result = XEP_0454.decrypt(BytesIO(ciphertext), fragment) + + self.assertEqual(plain, result) + + def testEncryptDecrypt(self): + plain = b'a' * 4096 + b'qwertyuiop' + ciphertext, fragment = XEP_0454.encrypt(input_file=BytesIO(plain)) + result = XEP_0454.decrypt(BytesIO(ciphertext), fragment) + + self.assertEqual(plain, result) + + def testFormatURL(self): + url = 'https://foo.bar' + fragment = 'a' * 88 + result = XEP_0454.format_url(url, fragment) + self.assertEqual('aesgcm://foo.bar#' + 'a' * 88, result) + +suite = unittest.TestLoader().loadTestsFromTestCase(TestMediaSharing) |