diff options
-rw-r--r-- | docs/api/plugins/index.rst | 3 | ||||
-rw-r--r-- | docs/api/plugins/xep_0313.rst | 1 | ||||
-rw-r--r-- | docs/api/plugins/xep_0441.rst | 18 | ||||
-rw-r--r-- | itests/test_mam.py | 45 | ||||
-rw-r--r-- | slixmpp/plugins/__init__.py | 1 | ||||
-rw-r--r-- | slixmpp/plugins/xep_0059/rsm.py | 6 | ||||
-rw-r--r-- | slixmpp/plugins/xep_0313/__init__.py | 4 | ||||
-rw-r--r-- | slixmpp/plugins/xep_0313/mam.py | 215 | ||||
-rw-r--r-- | slixmpp/plugins/xep_0313/stanza.py | 371 | ||||
-rw-r--r-- | slixmpp/plugins/xep_0441/__init__.py | 13 | ||||
-rw-r--r-- | slixmpp/plugins/xep_0441/mam_prefs.py | 75 | ||||
-rw-r--r-- | slixmpp/plugins/xep_0441/stanza.py | 91 | ||||
-rw-r--r-- | slixmpp/types.py | 2 | ||||
-rw-r--r-- | tests/test_stanza_xep_0313.py | 105 | ||||
-rw-r--r-- | tests/test_stream_xep_0313.py | 340 |
15 files changed, 1142 insertions, 148 deletions
diff --git a/docs/api/plugins/index.rst b/docs/api/plugins/index.rst index e6aa6913..6737d3c9 100644 --- a/docs/api/plugins/index.rst +++ b/docs/api/plugins/index.rst @@ -92,6 +92,5 @@ Plugin index xep_0428 xep_0437 xep_0439 + xep_0441 xep_0444 - - diff --git a/docs/api/plugins/xep_0313.rst b/docs/api/plugins/xep_0313.rst index f6271d72..fcd2af90 100644 --- a/docs/api/plugins/xep_0313.rst +++ b/docs/api/plugins/xep_0313.rst @@ -14,5 +14,6 @@ Stanza elements .. automodule:: slixmpp.plugins.xep_0313.stanza :members: + :member-order: bysource :undoc-members: diff --git a/docs/api/plugins/xep_0441.rst b/docs/api/plugins/xep_0441.rst new file mode 100644 index 00000000..34e51ea1 --- /dev/null +++ b/docs/api/plugins/xep_0441.rst @@ -0,0 +1,18 @@ + +XEP-0441: Message Archive Management Preferences +================================================ + +.. module:: slixmpp.plugins.xep_0441 + +.. autoclass:: XEP_0441 + :members: + :exclude-members: session_bind, plugin_init, plugin_end + + +Stanza elements +--------------- + +.. automodule:: slixmpp.plugins.xep_0441.stanza + :members: + :undoc-members: + diff --git a/itests/test_mam.py b/itests/test_mam.py index f61bc1e6..d10241bd 100644 --- a/itests/test_mam.py +++ b/itests/test_mam.py @@ -22,11 +22,14 @@ class TestMAM(SlixIntegration): """Make sure we can get messages from our archive""" # send messages first tok = randint(1, 999999) - self.clients[0].make_message(mto=self.clients[1].boundjid, mbody='coucou').send() + self.clients[0].make_message( + mto=self.clients[1].boundjid, + mbody=f'coucou {tok}' + ).send() await self.clients[1].wait_until('message') self.clients[1].make_message( mto=self.clients[0].boundjid, - mbody='coucou coucou %s' % tok, + mbody=f'coucou coucou {tok}', ).send() await self.clients[0].wait_until('message') @@ -48,8 +51,42 @@ class TestMAM(SlixIntegration): if count >= 2: break - self.assertEqual(msgs[0]['body'], 'coucou') - self.assertEqual(msgs[1]['body'], 'coucou coucou %s' % tok) + self.assertEqual(msgs[0]['body'], f'coucou {tok}') + self.assertEqual(msgs[1]['body'], f'coucou coucou {tok}') + + async def test_mam_iterate(self): + """Make sure we can iterate over messages from our archive""" + # send messages first + tok = randint(1, 999999) + self.clients[0].make_message( + mto=self.clients[1].boundjid, + mbody=f'coucou {tok}' + ).send() + await self.clients[1].wait_until('message') + self.clients[1].make_message( + mto=self.clients[0].boundjid, + mbody='coucou coucou %s' % tok, + ).send() + await self.clients[0].wait_until('message') + + # Get archive + retrieve = self.clients[0]['xep_0313'].iterate( + with_jid=JID(self.envjid('CI_ACCOUNT2')), + reverse=True, + rsm={'max': 1} + ) + msgs = [] + count = 0 + async for msg in retrieve: + msgs.append( + msg['mam_result']['forwarded']['stanza'] + ) + count += 1 + if count >= 2: + break + + self.assertEqual(msgs[0]['body'], f'coucou coucou {tok}') + self.assertEqual(msgs[1]['body'], f'coucou {tok}') suite = unittest.TestLoader().loadTestsFromTestCase(TestMAM) diff --git a/slixmpp/plugins/__init__.py b/slixmpp/plugins/__init__.py index d087f92b..55627113 100644 --- a/slixmpp/plugins/__init__.py +++ b/slixmpp/plugins/__init__.py @@ -110,5 +110,6 @@ __all__ = [ 'xep_0428', # Message Fallback 'xep_0437', # Room Activity Indicators 'xep_0439', # Quick Response + 'xep_0441', # Message Archive Management Preferences 'xep_0444', # Message Reactions ] diff --git a/slixmpp/plugins/xep_0059/rsm.py b/slixmpp/plugins/xep_0059/rsm.py index 61752af4..085ee474 100644 --- a/slixmpp/plugins/xep_0059/rsm.py +++ b/slixmpp/plugins/xep_0059/rsm.py @@ -135,6 +135,9 @@ class ResultIterator(AsyncIterator): not r[self.recv_interface]['rsm']['last']: raise StopAsyncIteration + if self.post_cb: + self.post_cb(r) + if r[self.recv_interface]['rsm']['count'] and \ r[self.recv_interface]['rsm']['first_index']: count = int(r[self.recv_interface]['rsm']['count']) @@ -147,9 +150,6 @@ class ResultIterator(AsyncIterator): self.start = r[self.recv_interface]['rsm']['first'] else: self.start = r[self.recv_interface]['rsm']['last'] - - if self.post_cb: - self.post_cb(r) return r except XMPPError: raise StopAsyncIteration diff --git a/slixmpp/plugins/xep_0313/__init__.py b/slixmpp/plugins/xep_0313/__init__.py index eace3dc7..2b7f3273 100644 --- a/slixmpp/plugins/xep_0313/__init__.py +++ b/slixmpp/plugins/xep_0313/__init__.py @@ -5,8 +5,10 @@ # See the file LICENSE for copying permissio from slixmpp.plugins.base import register_plugin -from slixmpp.plugins.xep_0313.stanza import Result, MAM, Preferences +from slixmpp.plugins.xep_0313.stanza import Result, MAM, Metadata from slixmpp.plugins.xep_0313.mam import XEP_0313 register_plugin(XEP_0313) + +__all__ = ['XEP_0313', 'Result', 'MAM', 'Metadata'] diff --git a/slixmpp/plugins/xep_0313/mam.py b/slixmpp/plugins/xep_0313/mam.py index eaa598a6..02efd3ce 100644 --- a/slixmpp/plugins/xep_0313/mam.py +++ b/slixmpp/plugins/xep_0313/mam.py @@ -5,8 +5,17 @@ # See the file LICENSE for copying permission import logging +from asyncio import Future +from collections.abc import AsyncGenerator from datetime import datetime -from typing import Any, Dict, Callable, Optional, Awaitable +from typing import ( + Any, + Awaitable, + Callable, + Dict, + Optional, + Tuple, +) from slixmpp import JID from slixmpp.stanza import Message, Iq @@ -15,6 +24,7 @@ from slixmpp.xmlstream.matcher import MatchXMLMask from slixmpp.xmlstream import register_stanza_plugin from slixmpp.plugins import BasePlugin from slixmpp.plugins.xep_0313 import stanza +from slixmpp.plugins.xep_0004.stanza import Form log = logging.getLogger(__name__) @@ -28,17 +38,25 @@ class XEP_0313(BasePlugin): name = 'xep_0313' description = 'XEP-0313: Message Archive Management' - dependencies = {'xep_0030', 'xep_0050', 'xep_0059', 'xep_0297'} + dependencies = { + 'xep_0004', 'xep_0030', 'xep_0050', 'xep_0059', 'xep_0297' + } stanza = stanza def plugin_init(self): + register_stanza_plugin(stanza.MAM, Form) register_stanza_plugin(Iq, stanza.MAM) - register_stanza_plugin(Iq, stanza.Preferences) register_stanza_plugin(Message, stanza.Result) register_stanza_plugin(Iq, stanza.Fin) - register_stanza_plugin(stanza.Result, self.xmpp['xep_0297'].stanza.Forwarded) + register_stanza_plugin( + stanza.Result, + self.xmpp['xep_0297'].stanza.Forwarded + ) register_stanza_plugin(stanza.MAM, self.xmpp['xep_0059'].stanza.Set) register_stanza_plugin(stanza.Fin, self.xmpp['xep_0059'].stanza.Set) + register_stanza_plugin(Iq, stanza.Metadata) + register_stanza_plugin(stanza.Metadata, stanza.Start) + register_stanza_plugin(stanza.Metadata, stanza.End) def retrieve( self, @@ -66,16 +84,10 @@ class XEP_0313(BasePlugin): :param bool iterator: Use RSM and iterate over a paginated query :param dict rsm: RSM custom options """ - iq = self.xmpp.Iq() + iq, stanza_mask = self._pre_mam_retrieve( + jid, start, end, with_jid, ifrom + ) query_id = iq['id'] - - iq['to'] = jid - iq['from'] = ifrom - iq['type'] = 'set' - iq['mam']['queryid'] = query_id - iq['mam']['start'] = start - iq['mam']['end'] = end - iq['mam']['with'] = with_jid amount = 10 if rsm: for key, value in rsm.items(): @@ -84,12 +96,6 @@ class XEP_0313(BasePlugin): amount = value cb_data = {} - stanza_mask = self.xmpp.Message() - stanza_mask.xml.remove(stanza_mask.xml.find('{urn:xmpp:sid:0}origin-id')) - del stanza_mask['id'] - del stanza_mask['lang'] - stanza_mask['from'] = jid - stanza_mask['mam_result']['queryid'] = query_id xml_mask = str(stanza_mask) def pre_cb(query: Iq) -> None: @@ -106,11 +112,14 @@ class XEP_0313(BasePlugin): results = cb_data['collector'].stop() if result['type'] == 'result': result['mam']['results'] = results + result['mam_fin']['results'] = results if iterator: - return self.xmpp['xep_0059'].iterate(iq, 'mam', 'results', amount=amount, - reverse=reverse, recv_interface='mam_fin', - pre_cb=pre_cb, post_cb=post_cb) + return self.xmpp['xep_0059'].iterate( + iq, 'mam', 'results', amount=amount, + reverse=reverse, recv_interface='mam_fin', + pre_cb=pre_cb, post_cb=post_cb + ) collector = Collector( 'MAM_Results_%s' % query_id, @@ -126,26 +135,144 @@ class XEP_0313(BasePlugin): return iq.send(timeout=timeout, callback=wrapped_cb) - def get_preferences(self, timeout=None, callback=None): - iq = self.xmpp.Iq() - iq['type'] = 'get' + async def iterate( + self, + jid: Optional[JID] = None, + start: Optional[datetime] = None, + end: Optional[datetime] = None, + with_jid: Optional[JID] = None, + ifrom: Optional[JID] = None, + reverse: bool = False, + rsm: Optional[Dict[str, Any]] = None, + total: Optional[int] = None, + ) -> AsyncGenerator: + """ + Iterate over each message of MAM query. + + :param jid: Entity holding the MAM records + :param start: MAM query start time + :param end: MAM query end time + :param with_jid: Filter results on this JID + :param ifrom: To change the from address of the query + :param reverse: Get the results in reverse order + :param rsm: RSM custom options + :param total: A number of messages received after which the query + should stop. + """ + iq, stanza_mask = self._pre_mam_retrieve( + jid, start, end, with_jid, ifrom + ) query_id = iq['id'] - iq['mam_prefs']['query_id'] = query_id - return iq.send(timeout=timeout, callback=callback) - - def set_preferences(self, jid=None, default=None, always=None, never=None, - ifrom=None, timeout=None, callback=None): - iq = self.xmpp.Iq() - iq['type'] = 'set' - iq['to'] = jid - iq['from'] = ifrom - iq['mam_prefs']['default'] = default - iq['mam_prefs']['always'] = always - iq['mam_prefs']['never'] = never - return iq.send(timeout=timeout, callback=callback) - - def get_configuration_commands(self, jid, **kwargs): - return self.xmpp['xep_0030'].get_items( - jid=jid, - node='urn:xmpp:mam#configure', - **kwargs) + amount = 10 + + if rsm: + for key, value in rsm.items(): + iq['mam']['rsm'][key] = str(value) + if key == 'max': + amount = value + cb_data = {} + + def pre_cb(query: Iq) -> None: + stanza_mask['mam_result']['queryid'] = query['id'] + xml_mask = str(stanza_mask) + query['mam']['queryid'] = query['id'] + collector = Collector( + 'MAM_Results_%s' % query_id, + MatchXMLMask(xml_mask)) + self.xmpp.register_handler(collector) + cb_data['collector'] = collector + + def post_cb(result: Iq) -> None: + results = cb_data['collector'].stop() + if result['type'] == 'result': + result['mam']['results'] = results + result['mam_fin']['results'] = results + + iterator = self.xmpp['xep_0059'].iterate( + iq, 'mam', 'results', amount=amount, + reverse=reverse, recv_interface='mam_fin', + pre_cb=pre_cb, post_cb=post_cb + ) + recv_count = 0 + + async for page in iterator: + messages = [message for message in page['mam']['results']] + if reverse: + messages.reverse() + for message in messages: + yield message + recv_count += 1 + if total is not None and recv_count >= total: + break + if total is not None and recv_count >= total: + break + + def _pre_mam_retrieve( + self, + jid: Optional[JID] = None, + start: Optional[datetime] = None, + end: Optional[datetime] = None, + with_jid: Optional[JID] = None, + ifrom: Optional[JID] = None, + ) -> Tuple[Iq, Message]: + """Build the IQ and stanza mask for MAM results + """ + iq = self.xmpp.make_iq_set(ito=jid, ifrom=ifrom) + query_id = iq['id'] + iq['mam']['queryid'] = query_id + iq['mam']['start'] = start + iq['mam']['end'] = end + iq['mam']['with'] = with_jid + + stanza_mask = self.xmpp.Message() + + auto_origin = stanza_mask.xml.find('{urn:xmpp:sid:0}origin-id') + if auto_origin is not None: + stanza_mask.xml.remove(auto_origin) + del stanza_mask['id'] + del stanza_mask['lang'] + stanza_mask['from'] = jid + stanza_mask['mam_result']['queryid'] = query_id + + return (iq, stanza_mask) + + async def get_fields(self, jid: Optional[JID] = None, **iqkwargs) -> Form: + """Get MAM query fields. + + .. versionaddedd:: 1.8.0 + + :param jid: JID to retrieve the policy from. + :return: The Form of allowed options + """ + ifrom = iqkwargs.pop('ifrom', None) + iq = self.xmpp.make_iq_get(ito=jid, ifrom=ifrom) + iq.enable('mam') + result = await iq.send(**iqkwargs) + return result['mam']['form'] + + async def get_configuration_commands(self, jid: Optional[JID], + **discokwargs) -> Future: + """Get the list of MAM advanced configuration commands. + + .. versionchanged:: 1.8.0 + + :param jid: JID to get the commands from. + """ + if jid is None: + jid = self.xmpp.boundjid.bare + return await self.xmpp['xep_0030'].get_items( + jid=jid, + node='urn:xmpp:mam#configure', + **discokwargs + ) + + def get_archive_metadata(self, jid: Optional[JID] = None, + **iqkwargs) -> Future: + """Get the archive metadata from a JID. + + :param jid: JID to get the metadata from. + """ + ifrom = iqkwargs.pop('ifrom', None) + iq = self.xmpp.make_iq_get(ito=jid, ifrom=ifrom) + iq.enable('mam_metadata') + return iq.send(**iqkwargs) diff --git a/slixmpp/plugins/xep_0313/stanza.py b/slixmpp/plugins/xep_0313/stanza.py index 4e43eeba..6633717f 100644 --- a/slixmpp/plugins/xep_0313/stanza.py +++ b/slixmpp/plugins/xep_0313/stanza.py @@ -1,159 +1,342 @@ - # Slixmpp: The Slick XMPP Library # Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout # This file is part of Slixmpp. # See the file LICENSE for copying permissio -import datetime as dt - +from datetime import datetime +from typing import ( + Any, + Iterable, + List, + Optional, + Set, + Union, +) + +from slixmpp.stanza import Message from slixmpp.jid import JID from slixmpp.xmlstream import ElementBase, ET -from slixmpp.plugins import xep_0082, xep_0004 +from slixmpp.plugins import xep_0082 class MAM(ElementBase): + """A MAM Query element. + + .. code-block:: xml + + <iq type='set' id='juliet1'> + <query xmlns='urn:xmpp:mam:2'> + <x xmlns='jabber:x:data' type='submit'> + <field var='FORM_TYPE' type='hidden'> + <value>urn:xmpp:mam:2</value> + </field> + <field var='with'> + <value>juliet@capulet.lit</value> + </field> + </x> + </query> + </iq> + + """ name = 'query' namespace = 'urn:xmpp:mam:2' plugin_attrib = 'mam' - interfaces = {'queryid', 'start', 'end', 'with', 'results'} - sub_interfaces = {'start', 'end', 'with'} + #: Available interfaces: + #: + #: - ``queryid``: The MAM query id + #: - ``start`` and ``end``: Temporal boundaries of the query + #: - ``with``: JID of the other entity the conversation is with + #: - ``after_id``: Fetch stanzas after this specific ID + #: - ``before_id``: Fetch stanzas before this specific ID + #: - ``ids``: Fetch the stanzas matching those IDs + #: - ``results``: pseudo-interface used to accumulate MAM results during + #: fetch, not relevant for the stanza itself. + interfaces = { + 'queryid', 'start', 'end', 'with', 'results', + 'before_id', 'after_id', 'ids', + } + sub_interfaces = {'start', 'end', 'with', 'before_id', 'after_id', 'ids'} def setup(self, xml=None): ElementBase.setup(self, xml) - self._form = xep_0004.stanza.Form() - self._form['type'] = 'submit' - field = self._form.add_field(var='FORM_TYPE', ftype='hidden', - value='urn:xmpp:mam:2') - self.append(self._form) - self._results = [] - - def __get_fields(self): - return self._form.get_fields() - - def get_start(self): - fields = self.__get_fields() + self._results: List[Message] = [] + + def _setup_form(self): + found = self.xml.find( + '{jabber:x:data}x/' + '{jabber:x:data}field[@var="FORM_TYPE"]/' + "{jabber:x:data}value[.='urn:xmpp:mam:2']" + ) + if found is None: + self['form']['type'] = 'submit' + self['form'].add_field( + var='FORM_TYPE', ftype='hidden', value='urn:xmpp:mam:2' + ) + + def get_fields(self): + form = self.get_plugin('form', check=True) + if not form: + return {} + return form.get_fields() + + def get_start(self) -> Optional[datetime]: + fields = self.get_fields() field = fields.get('start') if field: return xep_0082.parse(field['value']) + return None - def set_start(self, value): - if isinstance(value, dt.datetime): + def set_start(self, value: Union[str, datetime]): + self._setup_form() + if isinstance(value, datetime): value = xep_0082.format_datetime(value) - fields = self.__get_fields() - field = fields.get('start') - if field: - field['value'] = value - else: - field = self._form.add_field(var='start') - field['value'] = value + self.set_custom_field('start', value) - def get_end(self): - fields = self.__get_fields() + def get_end(self) -> Optional[datetime]: + fields = self.get_fields() field = fields.get('end') if field: return xep_0082.parse(field['value']) + return None - def set_end(self, value): - if isinstance(value, dt.datetime): + def set_end(self, value: Union[str, datetime]): + if isinstance(value, datetime): value = xep_0082.format_datetime(value) - fields = self.__get_fields() - field = fields.get('end') - if field: - field['value'] = value - else: - field = self._form.add_field(var='end') - field['value'] = value + self.set_custom_field('end', value) - def get_with(self): - fields = self.__get_fields() + def get_with(self) -> Optional[JID]: + fields = self.get_fields() field = fields.get('with') if field: return JID(field['value']) + return None - def set_with(self, value): - fields = self.__get_fields() - field = fields.get('with') + def set_with(self, value: JID): + self.set_custom_field('with', value) + + def set_custom_field(self, fieldname: str, value: Any): + self._setup_form() + fields = self.get_fields() + field = fields.get(fieldname) if field: - field['with'] = str(value) + field['value'] = str(value) else: - field = self._form.add_field(var='with') + field = self['form'].add_field(var=fieldname) field['value'] = str(value) + + def get_custom_field(self, fieldname: str) -> Optional[str]: + fields = self.get_fields() + field = fields.get(fieldname) + if field: + return field['value'] + return None + + def set_before_id(self, value: str): + self.set_custom_field('before-id', value) + + def get_before_id(self): + self.get_custom_field('before-id') + + def set_after_id(self, value: str): + self.set_custom_field('after-id', value) + + def get_after_id(self): + self.get_custom_field('after-id') + + def set_ids(self, value: List[str]): + self._setup_form() + fields = self.get_fields() + field = fields.get('ids') + if field: + field['ids'] = value + else: + field = self['form'].add_field(var='ids') + field['value'] = value + + def get_ids(self): + self.get_custom_field('id') + # The results interface is meant only as an easy # way to access the set of collected message responses # from the query. - def get_results(self): + def get_results(self) -> List[Message]: return self._results - def set_results(self, values): + def set_results(self, values: List[Message]): self._results = values def del_results(self): self._results = [] -class Preferences(ElementBase): - name = 'prefs' +class Fin(ElementBase): + """A MAM fin element (end of query). + + .. code-block:: xml + + <iq type='result' id='juliet1'> + <fin xmlns='urn:xmpp:mam:2'> + <set xmlns='http://jabber.org/protocol/rsm'> + <first index='0'>28482-98726-73623</first> + <last>09af3-cc343-b409f</last> + </set> + </fin> + </iq> + + """ + name = 'fin' namespace = 'urn:xmpp:mam:2' - plugin_attrib = 'mam_prefs' - interfaces = {'default', 'always', 'never'} - sub_interfaces = {'always', 'never'} + plugin_attrib = 'mam_fin' + interfaces = {'results'} - def get_always(self): - results = set() + def setup(self, xml=None): + ElementBase.setup(self, xml) + self._results: List[Message] = [] - jids = self.xml.findall('{%s}always/{%s}jid' % ( - self.namespace, self.namespace)) + # The results interface is meant only as an easy + # way to access the set of collected message responses + # from the query. - for jid in jids: - results.add(JID(jid.text)) + def get_results(self) -> List[Message]: + return self._results - return results + def set_results(self, values: List[Message]): + self._results = values + + def del_results(self): + self._results = [] - def set_always(self, value): - self._set_sub_text('always', '', keep=True) - always = self.xml.find('{%s}always' % self.namespace) - always.clear() - if not isinstance(value, (list, set)): - value = [value] +class Result(ElementBase): + """A MAM result payload. + + .. code-block:: xml + + <message id='aeb213' to='juliet@capulet.lit/chamber'> + <result xmlns='urn:xmpp:mam:2' queryid='f27' id='28482-98726-73623'> + <forwarded xmlns='urn:xmpp:forward:0'> + <delay xmlns='urn:xmpp:delay' stamp='2010-07-10T23:08:25Z'/> + <message xmlns='jabber:client' from="witch@shakespeare.lit" + to="macbeth@shakespeare.lit"> + <body>Hail to thee</body> + </message> + </forwarded> + </result> + </message> + """ + name = 'result' + namespace = 'urn:xmpp:mam:2' + plugin_attrib = 'mam_result' + #: Available interfaces: + #: + #: - ``queryid``: MAM queryid + #: - ``id``: ID of the result + interfaces = {'queryid', 'id'} - for jid in value: - jid_xml = ET.Element('{%s}jid' % self.namespace) - jid_xml.text = str(jid) - always.append(jid_xml) - def get_never(self): - results = set() +class Metadata(ElementBase): + """Element containing archive metadata - jids = self.xml.findall('{%s}never/{%s}jid' % ( - self.namespace, self.namespace)) + .. code-block:: xml - for jid in jids: - results.add(JID(jid.text)) + <iq type='result' id='jui8921rr9'> + <metadata xmlns='urn:xmpp:mam:2'> + <start id='YWxwaGEg' timestamp='2008-08-22T21:09:04Z' /> + <end id='b21lZ2Eg' timestamp='2020-04-20T14:34:21Z' /> + </metadata> + </iq> - return results + """ + name = 'metadata' + namespace = 'urn:xmpp:mam:2' + plugin_attrib = 'mam_metadata' - def set_never(self, value): - self._set_sub_text('never', '', keep=True) - never = self.xml.find('{%s}never' % self.namespace) - never.clear() - if not isinstance(value, (list, set)): - value = [value] +class Start(ElementBase): + """Metadata about the start of an archive. - for jid in value: - jid_xml = ET.Element('{%s}jid' % self.namespace) - jid_xml.text = str(jid) - never.append(jid_xml) + .. code-block:: xml + <iq type='result' id='jui8921rr9'> + <metadata xmlns='urn:xmpp:mam:2'> + <start id='YWxwaGEg' timestamp='2008-08-22T21:09:04Z' /> + <end id='b21lZ2Eg' timestamp='2020-04-20T14:34:21Z' /> + </metadata> + </iq> -class Fin(ElementBase): - name = 'fin' + """ + name = 'start' namespace = 'urn:xmpp:mam:2' - plugin_attrib = 'mam_fin' - -class Result(ElementBase): - name = 'result' + plugin_attrib = name + #: Available interfaces: + #: + #: - ``id``: ID of the first message of the archive + #: - ``timestamp`` (``datetime``): timestamp of the first message of the + #: archive + interfaces = {'id', 'timestamp'} + + def get_timestamp(self) -> Optional[datetime]: + """Get the timestamp. + + :returns: The timestamp. + """ + stamp = self.xml.attrib.get('timestamp', None) + if stamp is not None: + return xep_0082.parse(stamp) + return stamp + + def set_timestamp(self, value: Union[datetime, str]): + """Set the timestamp. + + :param value: Value of the timestamp (either a datetime or a + XEP-0082 timestamp string. + """ + if isinstance(value, str): + value = xep_0082.parse(value) + value = xep_0082.format_datetime(value) + self.xml.attrib['timestamp'] = value + + +class End(ElementBase): + """Metadata about the end of an archive. + + .. code-block:: xml + + <iq type='result' id='jui8921rr9'> + <metadata xmlns='urn:xmpp:mam:2'> + <start id='YWxwaGEg' timestamp='2008-08-22T21:09:04Z' /> + <end id='b21lZ2Eg' timestamp='2020-04-20T14:34:21Z' /> + </metadata> + </iq> + + """ + name = 'end' namespace = 'urn:xmpp:mam:2' - plugin_attrib = 'mam_result' - interfaces = {'queryid', 'id'} + plugin_attrib = name + #: Available interfaces: + #: + #: - ``id``: ID of the first message of the archive + #: - ``timestamp`` (``datetime``): timestamp of the first message of the + #: archive + interfaces = {'id', 'timestamp'} + + def get_timestamp(self) -> Optional[datetime]: + """Get the timestamp. + + :returns: The timestamp. + """ + stamp = self.xml.attrib.get('timestamp', None) + if stamp is not None: + return xep_0082.parse(stamp) + return stamp + + def set_timestamp(self, value: Union[datetime, str]): + """Set the timestamp. + + :param value: Value of the timestamp (either a datetime or a + XEP-0082 timestamp string. + """ + if isinstance(value, str): + value = xep_0082.parse(value) + value = xep_0082.format_datetime(value) + self.xml.attrib['timestamp'] = value diff --git a/slixmpp/plugins/xep_0441/__init__.py b/slixmpp/plugins/xep_0441/__init__.py new file mode 100644 index 00000000..0e169082 --- /dev/null +++ b/slixmpp/plugins/xep_0441/__init__.py @@ -0,0 +1,13 @@ +# Slixmpp: The Slick XMPP Library +# Copyright (C) 2021 Mathieu Pasquet +# This file is part of Slixmpp. +# See the file LICENSE for copying permissio +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0441.stanza import Preferences +from slixmpp.plugins.xep_0441.mam_prefs import XEP_0441 + + +register_plugin(XEP_0441) + +__all__ = ['XEP_0441', 'Preferences'] diff --git a/slixmpp/plugins/xep_0441/mam_prefs.py b/slixmpp/plugins/xep_0441/mam_prefs.py new file mode 100644 index 00000000..06a830bc --- /dev/null +++ b/slixmpp/plugins/xep_0441/mam_prefs.py @@ -0,0 +1,75 @@ +# Slixmpp: The Slick XMPP Library +# Copyright (C) 2021 Mathieu Pasquet +# This file is part of Slixmpp. +# See the file LICENSE for copying permission +import logging + +from asyncio import Future +from typing import ( + List, + Optional, + Tuple, +) + +from slixmpp import JID +from slixmpp.types import MAMDefault +from slixmpp.stanza import Iq +from slixmpp.xmlstream import register_stanza_plugin +from slixmpp.plugins import BasePlugin +from slixmpp.plugins.xep_0441 import stanza + + +log = logging.getLogger(__name__) + + +class XEP_0441(BasePlugin): + + """ + XEP-0441: Message Archive Management Preferences + """ + + name = 'xep_0441' + description = 'XEP-0441: Message Archive Management Preferences' + dependencies = {'xep_0313'} + stanza = stanza + + def plugin_init(self): + register_stanza_plugin(Iq, stanza.Preferences) + + async def get_preferences(self, **iqkwargs + ) -> Tuple[MAMDefault, List[JID], List[JID]]: + """Get the current MAM preferences. + + :returns: A tuple of MAM preferences with (default, always, never) + """ + ifrom = iqkwargs.pop('ifrom', None) + ito = iqkwargs.pop('ito', None) + iq = self.xmpp.make_iq_get(ito=ito, ifrom=ifrom) + iq['type'] = 'get' + query_id = iq['id'] + iq['mam_prefs']['query_id'] = query_id + result = await iq.send(**iqkwargs) + return ( + result['mam_prefs']['default'], + result['mam_prefs']['always'], + result['mam_prefs']['never'] + ) + + def set_preferences(self, default: Optional[MAMDefault] = 'roster', + always: Optional[List[JID]] = None, + never: Optional[List[JID]] = None, *, + ito: Optional[JID] = None, ifrom: Optional[JID] = None, + **iqkwargs) -> Future: + """Set MAM Preferences. + + The server answer MAY contain different items. + + :param default: Default behavior (one of 'always', 'never', 'roster'). + :param always: List of JIDs whose messages will always be stored. + :param never: List of JIDs whose messages will never be stored. + """ + iq = self.xmpp.make_iq_set(ito=ito, ifrom=ifrom) + iq['mam_prefs']['default'] = default + iq['mam_prefs']['always'] = always + iq['mam_prefs']['never'] = never + return iq.send(**iqkwargs) diff --git a/slixmpp/plugins/xep_0441/stanza.py b/slixmpp/plugins/xep_0441/stanza.py new file mode 100644 index 00000000..99dbe802 --- /dev/null +++ b/slixmpp/plugins/xep_0441/stanza.py @@ -0,0 +1,91 @@ +# Slixmpp: The Slick XMPP Library +# Copyright (C) 2021 Mathieu Pasquet +# This file is part of Slixmpp. +# See the file LICENSE for copying permissio +from typing import ( + Iterable, + Set, +) + +from slixmpp.jid import JID +from slixmpp.xmlstream import ElementBase, ET + + +class Preferences(ElementBase): + """MAM Preferences payload. + + .. code-block:: xml + + <iq type='set' id='juliet3'> + <prefs xmlns='urn:xmpp:mam:2' default='roster'> + <always> + <jid>romeo@montague.lit</jid> + </always> + <never> + <jid>montague@montague.lit</jid> + </never> + </prefs> + </iq> + + """ + name = 'prefs' + namespace = 'urn:xmpp:mam:2' + plugin_attrib = 'mam_prefs' + #: Available interfaces: + #: + #: - ``default``: Default MAM policy (must be one of 'roster', 'always', + #: 'never' + #: - ``always`` (``List[JID]``): list of JIDs to always store + #: conversations with. + #: - ``never`` (``List[JID]``): list of JIDs to never store + #: conversations with. + interfaces = {'default', 'always', 'never'} + sub_interfaces = {'always', 'never'} + + def get_always(self) -> Set[JID]: + results = set() + + jids = self.xml.findall('{%s}always/{%s}jid' % ( + self.namespace, self.namespace)) + + for jid in jids: + results.add(JID(jid.text)) + + return results + + def set_always(self, value: Iterable[JID]): + self._set_sub_text('always', '', keep=True) + always = self.xml.find('{%s}always' % self.namespace) + always.clear() + + if not isinstance(value, (list, set)): + value = [value] + + for jid in value: + jid_xml = ET.Element('{%s}jid' % self.namespace) + jid_xml.text = str(jid) + always.append(jid_xml) + + def get_never(self) -> Set[JID]: + results = set() + + jids = self.xml.findall('{%s}never/{%s}jid' % ( + self.namespace, self.namespace)) + + for jid in jids: + results.add(JID(jid.text)) + + return results + + def set_never(self, value: Iterable[JID]): + self._set_sub_text('never', '', keep=True) + never = self.xml.find('{%s}never' % self.namespace) + never.clear() + + if not isinstance(value, (list, set)): + value = [value] + + for jid in value: + jid_xml = ET.Element('{%s}jid' % self.namespace) + jid_xml.text = str(jid) + never.append(jid_xml) diff --git a/slixmpp/types.py b/slixmpp/types.py index b9a1d319..453d25e3 100644 --- a/slixmpp/types.py +++ b/slixmpp/types.py @@ -76,3 +76,5 @@ MucRoomItemKeys = Literal[ OptJid = Optional[JID] JidStr = Union[str, JID] OptJidStr = Optional[Union[str, JID]] + +MAMDefault = Literal['always', 'never', 'roster'] diff --git a/tests/test_stanza_xep_0313.py b/tests/test_stanza_xep_0313.py new file mode 100644 index 00000000..ac5fcb14 --- /dev/null +++ b/tests/test_stanza_xep_0313.py @@ -0,0 +1,105 @@ +import unittest +from slixmpp import JID, Iq, Message +from slixmpp.test import SlixTest +from slixmpp.plugins.xep_0313 import stanza +from slixmpp.plugins.xep_0004.stanza import Form +from slixmpp.plugins.xep_0297 import stanza as fstanza +from slixmpp.plugins.xep_0059 import stanza as rstanza +from slixmpp.xmlstream import register_stanza_plugin + + +class TestMAM(SlixTest): + + def setUp(self): + register_stanza_plugin(stanza.MAM, Form) + register_stanza_plugin(Iq, stanza.MAM) + register_stanza_plugin(Message, stanza.Result) + register_stanza_plugin(Iq, stanza.Fin) + register_stanza_plugin( + stanza.Result, + fstanza.Forwarded + ) + register_stanza_plugin(stanza.MAM, rstanza.Set) + register_stanza_plugin(stanza.Fin, rstanza.Set) + + register_stanza_plugin(Iq, stanza.Metadata) + register_stanza_plugin(stanza.Metadata, stanza.Start) + register_stanza_plugin(stanza.Metadata, stanza.End) + + def testMAMQuery(self): + """Test that we can build a simple MAM query.""" + iq = Iq() + iq['type'] = 'set' + iq['mam']['queryid'] = 'f27' + + self.check(iq, """ + <iq type='set'> + <query xmlns='urn:xmpp:mam:2' queryid='f27'/> + </iq> + """) + + def testMAMQueryOptions(self): + """Test that we can build a mam query with all options.""" + iq = Iq() + iq['type'] = 'set' + iq['mam']['with'] = JID('juliet@capulet.lit') + iq['mam']['start'] = '2010-06-07T00:00:00Z' + iq['mam']['end'] = '2010-07-07T13:23:54Z' + iq['mam']['after_id'] = 'id1' + iq['mam']['before_id'] = 'id2' + iq['mam']['ids'] = ['a', 'b', 'c'] + + self.check(iq, """ + <iq type='set'> + <query xmlns='urn:xmpp:mam:2'> + <x xmlns='jabber:x:data' type='submit'> + <field var='FORM_TYPE' type='hidden'> + <value>urn:xmpp:mam:2</value> + </field> + <field var='with'> + <value>juliet@capulet.lit</value> + </field> + <field var='start'> + <value>2010-06-07T00:00:00Z</value> + </field> + <field var='end'> + <value>2010-07-07T13:23:54Z</value> + </field> + <field var='after-id'> + <value>id1</value> + </field> + <field var='before-id'> + <value>id2</value> + </field> + <field var='ids'> + <value>a</value> + <value>b</value> + <value>c</value> + </field> + </x> + </query> + </iq> + """, use_values=False) + + def testMAMMetadata(self): + """Test that we can build a MAM metadata payload""" + + iq = Iq() + iq['type'] = 'result' + iq['mam_metadata']['start']['id'] = 'YWxwaGEg' + iq['mam_metadata']['start']['timestamp'] = '2008-08-22T21:09:04Z' + iq['mam_metadata']['end']['id'] = 'b21lZ2Eg' + iq['mam_metadata']['end']['timestamp'] = '2020-04-20T14:34:21Z' + + self.check(iq, """ + <iq type='result'> + <metadata xmlns='urn:xmpp:mam:2'> + <start id='YWxwaGEg' timestamp='2008-08-22T21:09:04Z' /> + <end id='b21lZ2Eg' timestamp='2020-04-20T14:34:21Z' /> + </metadata> + </iq> + """) + + + +suite = unittest.TestLoader().loadTestsFromTestCase(TestMAM) diff --git a/tests/test_stream_xep_0313.py b/tests/test_stream_xep_0313.py new file mode 100644 index 00000000..25a3a926 --- /dev/null +++ b/tests/test_stream_xep_0313.py @@ -0,0 +1,340 @@ +import unittest +from datetime import datetime +from slixmpp.test import SlixTest +from slixmpp import JID + + +class TestMAM(SlixTest): + + def setUp(self): + self.stream_start(plugins=['xep_0313']) + + def tearDown(self): + self.stream_close() + + def testRetrieveSimple(self): + """Test requesting MAM messages without RSM""" + + msgs = [] + + async def test(): + iq = await self.xmpp['xep_0313'].retrieve() + for message in iq['mam']['results']: + msgs.append(message) + + fut = self.xmpp.wrap(test()) + self.wait_() + self.send(""" + <iq type='set' id='1'> + <query xmlns='urn:xmpp:mam:2' queryid='1' /> + </iq> + """) + + self.recv(""" + <message id='abc' to='tester@localhost/resource'> + <result xmlns='urn:xmpp:mam:2' queryid='1' + id='28482-98726-73623'> + <forwarded xmlns='urn:xmpp:forward:0'> + <delay xmlns='urn:xmpp:delay' stamp='2010-07-10T23:08:25Z'/> + <message xmlns='jabber:client' from="witch@shakespeare.lit" + to="tester@localhost"> + <body>Hail to thee</body> + </message> + </forwarded> + </result> + </message> + """) + + self.recv(""" + <iq type="result" id="1" to="tester@localhost"> + <fin xmlns="urn:xmpp:mam:2"> + <first index='0'>28482-98726-73623</first> + <last>28482-98726-73623</last> + </fin> + </iq> + """) + + self.run_coro(fut) + self.assertEqual( + msgs[0]['mam_result']['forwarded']['message']['body'], + "Hail to thee", + ) + self.assertEqual(len(msgs),1) + + def testRetrieveRSM(self): + """Test requesting MAM messages with RSM""" + + msgs = [] + + async def test(): + iterator = self.xmpp['xep_0313'].retrieve( + with_jid=JID('toto@titi'), + start='2010-06-07T00:00:00Z', + iterator=True, + ) + async for page in iterator: + for message in page['mam']['results']: + msgs.append(message) + + fut = self.xmpp.wrap(test()) + self.wait_() + self.send(""" + <iq type='set' id='2'> + <query xmlns='urn:xmpp:mam:2' queryid='2'> + <x xmlns='jabber:x:data' type='submit'> + <field var='FORM_TYPE' type='hidden'> + <value>urn:xmpp:mam:2</value> + </field> + <field var='with'> + <value>toto@titi</value> + </field> + <field var='start'> + <value>2010-06-07T00:00:00Z</value> + </field> + </x> + <set xmlns="http://jabber.org/protocol/rsm"> + <max>10</max> + </set> + </query> + </iq> + """) + + self.recv(""" + <message id='abc' to='tester@localhost/resource'> + <result xmlns='urn:xmpp:mam:2' queryid='2' + id='28482-98726-73623'> + <forwarded xmlns='urn:xmpp:forward:0'> + <delay xmlns='urn:xmpp:delay' stamp='2010-07-10T23:08:25Z'/> + <message xmlns='jabber:client' from="witch@shakespeare.lit" + to="tester@localhost"> + <body>Hail to thee</body> + </message> + </forwarded> + </result> + </message> + """) + + self.recv(""" + <iq type="result" id="2" to="tester@localhost"> + <fin xmlns="urn:xmpp:mam:2"> + <set xmlns='http://jabber.org/protocol/rsm'> + <first index='0'>28482-98726-73623</first> + <last>28482-98726-73623</last> + <count>2</count> + </set> + </fin> + </iq> + """) + + self.send(""" + <iq type='set' id='3'> + <query xmlns='urn:xmpp:mam:2' queryid='3'> + <x xmlns='jabber:x:data' type='submit'> + <field var='FORM_TYPE' type='hidden'> + <value>urn:xmpp:mam:2</value> + </field> + <field var='with'> + <value>toto@titi</value> + </field> + <field var='start'> + <value>2010-06-07T00:00:00Z</value> + </field> + </x> + <set xmlns="http://jabber.org/protocol/rsm"> + <max>10</max> + <after>28482-98726-73623</after> + </set> + </query> + </iq> + """) + + self.recv(""" + <message id='abc' to='tester@localhost/resource'> + <result xmlns='urn:xmpp:mam:2' queryid='3' + id='28482-98726-73624'> + <forwarded xmlns='urn:xmpp:forward:0'> + <delay xmlns='urn:xmpp:delay' stamp='2010-07-10T23:08:26Z'/> + <message xmlns='jabber:client' from="witch@shakespeare.lit" + to="tester@localhost"> + <body>Hi Y'all</body> + </message> + </forwarded> + </result> + </message> + """) + + self.recv(""" + <iq type="result" id="3" to="tester@localhost"> + <fin xmlns="urn:xmpp:mam:2"> + <set xmlns='http://jabber.org/protocol/rsm'> + <first index='1'>28482-98726-73624</first> + <last>28482-98726-73624</last> + <count>2</count> + </set> + </fin> + </iq> + """) + + self.run_coro(fut) + self.assertEqual( + msgs[0]['mam_result']['forwarded']['message']['body'], + "Hail to thee", + ) + self.assertEqual( + msgs[1]['mam_result']['forwarded']['message']['body'], + "Hi Y'all", + ) + self.assertEqual(len(msgs), 2) + + def testIterate(self): + """Test iterating over MAM messages with RSM""" + + msgs = [] + + async def test(): + iterator = self.xmpp['xep_0313'].iterate( + with_jid=JID('toto@titi'), + start='2010-06-07T00:00:00Z', + ) + async for message in iterator: + msgs.append(message) + + fut = self.xmpp.wrap(test()) + self.wait_() + self.send(""" + <iq type='set' id='2'> + <query xmlns='urn:xmpp:mam:2' queryid='2'> + <x xmlns='jabber:x:data' type='submit'> + <field var='FORM_TYPE' type='hidden'> + <value>urn:xmpp:mam:2</value> + </field> + <field var='with'> + <value>toto@titi</value> + </field> + <field var='start'> + <value>2010-06-07T00:00:00Z</value> + </field> + </x> + <set xmlns="http://jabber.org/protocol/rsm"> + <max>10</max> + </set> + </query> + </iq> + """) + + self.recv(""" + <message id='abc' to='tester@localhost/resource'> + <result xmlns='urn:xmpp:mam:2' queryid='2' + id='28482-98726-73623'> + <forwarded xmlns='urn:xmpp:forward:0'> + <delay xmlns='urn:xmpp:delay' stamp='2010-07-10T23:08:25Z'/> + <message xmlns='jabber:client' from="witch@shakespeare.lit" + to="tester@localhost"> + <body>Hail to thee</body> + </message> + </forwarded> + </result> + </message> + """) + + self.recv(""" + <iq type="result" id="2" to="tester@localhost"> + <fin xmlns="urn:xmpp:mam:2"> + <set xmlns='http://jabber.org/protocol/rsm'> + <first index='0'>28482-98726-73623</first> + <last>28482-98726-73623</last> + <count>2</count> + </set> + </fin> + </iq> + """) + + self.send(""" + <iq type='set' id='3'> + <query xmlns='urn:xmpp:mam:2' queryid='3'> + <x xmlns='jabber:x:data' type='submit'> + <field var='FORM_TYPE' type='hidden'> + <value>urn:xmpp:mam:2</value> + </field> + <field var='with'> + <value>toto@titi</value> + </field> + <field var='start'> + <value>2010-06-07T00:00:00Z</value> + </field> + </x> + <set xmlns="http://jabber.org/protocol/rsm"> + <max>10</max> + <after>28482-98726-73623</after> + </set> + </query> + </iq> + """) + + self.recv(""" + <message id='abc' to='tester@localhost/resource'> + <result xmlns='urn:xmpp:mam:2' queryid='3' + id='28482-98726-73624'> + <forwarded xmlns='urn:xmpp:forward:0'> + <delay xmlns='urn:xmpp:delay' stamp='2010-07-10T23:08:26Z'/> + <message xmlns='jabber:client' from="witch@shakespeare.lit" + to="tester@localhost"> + <body>Hi Y'all</body> + </message> + </forwarded> + </result> + </message> + """) + + self.recv(""" + <iq type="result" id="3" to="tester@localhost"> + <fin xmlns="urn:xmpp:mam:2"> + <set xmlns='http://jabber.org/protocol/rsm'> + <first index='1'>28482-98726-73624</first> + <last>28482-98726-73624</last> + <count>2</count> + </set> + </fin> + </iq> + """) + + self.run_coro(fut) + self.assertEqual( + msgs[0]['mam_result']['forwarded']['message']['body'], + "Hail to thee", + ) + self.assertEqual( + msgs[1]['mam_result']['forwarded']['message']['body'], + "Hi Y'all", + ) + self.assertEqual(len(msgs), 2) + + def test_get_metadata(self): + """Test a MAM metadata retrieval""" + fut = self.xmpp.wrap( + self.xmpp.plugin['xep_0313'].get_archive_metadata() + ) + self.wait_() + self.send(""" + <iq type='get' id='1'> + <metadata xmlns='urn:xmpp:mam:2'/> + </iq> + """) + self.recv(""" + <iq type='result' id='1'> + <metadata xmlns='urn:xmpp:mam:2'> + <start id='YWxwaGEg' timestamp='2008-08-22T21:09:04Z' /> + <end id='b21lZ2Eg' timestamp='2020-04-20T14:34:21Z' /> + </metadata> + </iq> + """) + self.run_coro(fut) + result = fut.result() + self.assertEqual(result['mam_metadata']['start']['id'], "YWxwaGEg") + self.assertEqual( + result['mam_metadata']['start']['timestamp'], + datetime.fromisoformat('2008-08-22T21:09:04+00:00') + ) + + +suite = unittest.TestLoader().loadTestsFromTestCase(TestMAM) |