Merge branch 'mam-update' into 'master'

MAM Update

See merge request poezio/slixmpp!149
This commit is contained in:
mathieui 2021-03-09 21:20:14 +01:00
commit 7c86c43fc7
15 changed files with 1147 additions and 153 deletions

View File

@ -92,6 +92,5 @@ Plugin index
xep_0428 xep_0428
xep_0437 xep_0437
xep_0439 xep_0439
xep_0441
xep_0444 xep_0444

View File

@ -14,5 +14,6 @@ Stanza elements
.. automodule:: slixmpp.plugins.xep_0313.stanza .. automodule:: slixmpp.plugins.xep_0313.stanza
:members: :members:
:member-order: bysource
:undoc-members: :undoc-members:

View File

@ -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:

View File

@ -22,11 +22,14 @@ class TestMAM(SlixIntegration):
"""Make sure we can get messages from our archive""" """Make sure we can get messages from our archive"""
# send messages first # send messages first
tok = randint(1, 999999) 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') await self.clients[1].wait_until('message')
self.clients[1].make_message( self.clients[1].make_message(
mto=self.clients[0].boundjid, mto=self.clients[0].boundjid,
mbody='coucou coucou %s' % tok, mbody=f'coucou coucou {tok}',
).send() ).send()
await self.clients[0].wait_until('message') await self.clients[0].wait_until('message')
@ -48,8 +51,42 @@ class TestMAM(SlixIntegration):
if count >= 2: if count >= 2:
break break
self.assertEqual(msgs[0]['body'], 'coucou') self.assertEqual(msgs[0]['body'], f'coucou {tok}')
self.assertEqual(msgs[1]['body'], 'coucou coucou %s' % 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) suite = unittest.TestLoader().loadTestsFromTestCase(TestMAM)

View File

@ -110,5 +110,6 @@ __all__ = [
'xep_0428', # Message Fallback 'xep_0428', # Message Fallback
'xep_0437', # Room Activity Indicators 'xep_0437', # Room Activity Indicators
'xep_0439', # Quick Response 'xep_0439', # Quick Response
'xep_0441', # Message Archive Management Preferences
'xep_0444', # Message Reactions 'xep_0444', # Message Reactions
] ]

View File

@ -135,6 +135,9 @@ class ResultIterator(AsyncIterator):
not r[self.recv_interface]['rsm']['last']: not r[self.recv_interface]['rsm']['last']:
raise StopAsyncIteration raise StopAsyncIteration
if self.post_cb:
self.post_cb(r)
if r[self.recv_interface]['rsm']['count'] and \ if r[self.recv_interface]['rsm']['count'] and \
r[self.recv_interface]['rsm']['first_index']: r[self.recv_interface]['rsm']['first_index']:
count = int(r[self.recv_interface]['rsm']['count']) count = int(r[self.recv_interface]['rsm']['count'])
@ -147,9 +150,6 @@ class ResultIterator(AsyncIterator):
self.start = r[self.recv_interface]['rsm']['first'] self.start = r[self.recv_interface]['rsm']['first']
else: else:
self.start = r[self.recv_interface]['rsm']['last'] self.start = r[self.recv_interface]['rsm']['last']
if self.post_cb:
self.post_cb(r)
return r return r
except XMPPError: except XMPPError:
raise StopAsyncIteration raise StopAsyncIteration

View File

@ -5,8 +5,10 @@
# See the file LICENSE for copying permissio # See the file LICENSE for copying permissio
from slixmpp.plugins.base import register_plugin 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 from slixmpp.plugins.xep_0313.mam import XEP_0313
register_plugin(XEP_0313) register_plugin(XEP_0313)
__all__ = ['XEP_0313', 'Result', 'MAM', 'Metadata']

View File

@ -5,8 +5,17 @@
# See the file LICENSE for copying permission # See the file LICENSE for copying permission
import logging import logging
from asyncio import Future
from collections.abc import AsyncGenerator
from datetime import datetime 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 import JID
from slixmpp.stanza import Message, Iq 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.xmlstream import register_stanza_plugin
from slixmpp.plugins import BasePlugin from slixmpp.plugins import BasePlugin
from slixmpp.plugins.xep_0313 import stanza from slixmpp.plugins.xep_0313 import stanza
from slixmpp.plugins.xep_0004.stanza import Form
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -28,17 +38,25 @@ class XEP_0313(BasePlugin):
name = 'xep_0313' name = 'xep_0313'
description = 'XEP-0313: Message Archive Management' 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 stanza = stanza
def plugin_init(self): def plugin_init(self):
register_stanza_plugin(stanza.MAM, Form)
register_stanza_plugin(Iq, stanza.MAM) register_stanza_plugin(Iq, stanza.MAM)
register_stanza_plugin(Iq, stanza.Preferences)
register_stanza_plugin(Message, stanza.Result) register_stanza_plugin(Message, stanza.Result)
register_stanza_plugin(Iq, stanza.Fin) 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.MAM, self.xmpp['xep_0059'].stanza.Set)
register_stanza_plugin(stanza.Fin, 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( def retrieve(
self, self,
@ -66,16 +84,10 @@ class XEP_0313(BasePlugin):
:param bool iterator: Use RSM and iterate over a paginated query :param bool iterator: Use RSM and iterate over a paginated query
:param dict rsm: RSM custom options :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'] 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 amount = 10
if rsm: if rsm:
for key, value in rsm.items(): for key, value in rsm.items():
@ -84,12 +96,6 @@ class XEP_0313(BasePlugin):
amount = value amount = value
cb_data = {} 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) xml_mask = str(stanza_mask)
def pre_cb(query: Iq) -> None: def pre_cb(query: Iq) -> None:
@ -106,11 +112,14 @@ class XEP_0313(BasePlugin):
results = cb_data['collector'].stop() results = cb_data['collector'].stop()
if result['type'] == 'result': if result['type'] == 'result':
result['mam']['results'] = results result['mam']['results'] = results
result['mam_fin']['results'] = results
if iterator: if iterator:
return self.xmpp['xep_0059'].iterate(iq, 'mam', 'results', amount=amount, return self.xmpp['xep_0059'].iterate(
reverse=reverse, recv_interface='mam_fin', iq, 'mam', 'results', amount=amount,
pre_cb=pre_cb, post_cb=post_cb) reverse=reverse, recv_interface='mam_fin',
pre_cb=pre_cb, post_cb=post_cb
)
collector = Collector( collector = Collector(
'MAM_Results_%s' % query_id, 'MAM_Results_%s' % query_id,
@ -126,26 +135,144 @@ class XEP_0313(BasePlugin):
return iq.send(timeout=timeout, callback=wrapped_cb) return iq.send(timeout=timeout, callback=wrapped_cb)
def get_preferences(self, timeout=None, callback=None): async def iterate(
iq = self.xmpp.Iq() self,
iq['type'] = 'get' 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'] query_id = iq['id']
iq['mam_prefs']['query_id'] = query_id amount = 10
return iq.send(timeout=timeout, callback=callback)
def set_preferences(self, jid=None, default=None, always=None, never=None, if rsm:
ifrom=None, timeout=None, callback=None): for key, value in rsm.items():
iq = self.xmpp.Iq() iq['mam']['rsm'][key] = str(value)
iq['type'] = 'set' if key == 'max':
iq['to'] = jid amount = value
iq['from'] = ifrom cb_data = {}
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): def pre_cb(query: Iq) -> None:
return self.xmpp['xep_0030'].get_items( stanza_mask['mam_result']['queryid'] = query['id']
jid=jid, xml_mask = str(stanza_mask)
node='urn:xmpp:mam#configure', query['mam']['queryid'] = query['id']
**kwargs) 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)

View File

@ -1,159 +1,342 @@
# Slixmpp: The Slick XMPP Library # Slixmpp: The Slick XMPP Library
# Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout # Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout
# This file is part of Slixmpp. # This file is part of Slixmpp.
# See the file LICENSE for copying permissio # 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.jid import JID
from slixmpp.xmlstream import ElementBase, ET from slixmpp.xmlstream import ElementBase, ET
from slixmpp.plugins import xep_0082, xep_0004 from slixmpp.plugins import xep_0082
class MAM(ElementBase): 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' name = 'query'
namespace = 'urn:xmpp:mam:2' namespace = 'urn:xmpp:mam:2'
plugin_attrib = 'mam' plugin_attrib = 'mam'
interfaces = {'queryid', 'start', 'end', 'with', 'results'} #: Available interfaces:
sub_interfaces = {'start', 'end', 'with'} #:
#: - ``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): def setup(self, xml=None):
ElementBase.setup(self, xml) ElementBase.setup(self, xml)
self._form = xep_0004.stanza.Form() self._results: List[Message] = []
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): def _setup_form(self):
return self._form.get_fields() 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_start(self): def get_fields(self):
fields = self.__get_fields() 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') field = fields.get('start')
if field: if field:
return xep_0082.parse(field['value']) return xep_0082.parse(field['value'])
return None
def set_start(self, value): def set_start(self, value: Union[str, datetime]):
if isinstance(value, dt.datetime): self._setup_form()
if isinstance(value, datetime):
value = xep_0082.format_datetime(value) value = xep_0082.format_datetime(value)
fields = self.__get_fields() self.set_custom_field('start', value)
field = fields.get('start')
if field:
field['value'] = value
else:
field = self._form.add_field(var='start')
field['value'] = value
def get_end(self): def get_end(self) -> Optional[datetime]:
fields = self.__get_fields() fields = self.get_fields()
field = fields.get('end') field = fields.get('end')
if field: if field:
return xep_0082.parse(field['value']) return xep_0082.parse(field['value'])
return None
def set_end(self, value): def set_end(self, value: Union[str, datetime]):
if isinstance(value, dt.datetime): if isinstance(value, datetime):
value = xep_0082.format_datetime(value) value = xep_0082.format_datetime(value)
fields = self.__get_fields() self.set_custom_field('end', value)
field = fields.get('end')
if field:
field['value'] = value
else:
field = self._form.add_field(var='end')
field['value'] = value
def get_with(self): def get_with(self) -> Optional[JID]:
fields = self.__get_fields() fields = self.get_fields()
field = fields.get('with') field = fields.get('with')
if field: if field:
return JID(field['value']) return JID(field['value'])
return None
def set_with(self, value): def set_with(self, value: JID):
fields = self.__get_fields() self.set_custom_field('with', value)
field = fields.get('with')
def set_custom_field(self, fieldname: str, value: Any):
self._setup_form()
fields = self.get_fields()
field = fields.get(fieldname)
if field: if field:
field['with'] = str(value)
else:
field = self._form.add_field(var='with')
field['value'] = str(value) field['value'] = str(value)
else:
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 # The results interface is meant only as an easy
# way to access the set of collected message responses # way to access the set of collected message responses
# from the query. # from the query.
def get_results(self): def get_results(self) -> List[Message]:
return self._results return self._results
def set_results(self, values): def set_results(self, values: List[Message]):
self._results = values self._results = values
def del_results(self): def del_results(self):
self._results = [] self._results = []
class Preferences(ElementBase):
name = 'prefs'
namespace = 'urn:xmpp:mam:2'
plugin_attrib = 'mam_prefs'
interfaces = {'default', 'always', 'never'}
sub_interfaces = {'always', 'never'}
def get_always(self):
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):
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):
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):
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)
class Fin(ElementBase): 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' name = 'fin'
namespace = 'urn:xmpp:mam:2' namespace = 'urn:xmpp:mam:2'
plugin_attrib = 'mam_fin' plugin_attrib = 'mam_fin'
interfaces = {'results'}
def setup(self, xml=None):
ElementBase.setup(self, xml)
self._results: List[Message] = []
# 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) -> List[Message]:
return self._results
def set_results(self, values: List[Message]):
self._results = values
def del_results(self):
self._results = []
class Result(ElementBase): 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' name = 'result'
namespace = 'urn:xmpp:mam:2' namespace = 'urn:xmpp:mam:2'
plugin_attrib = 'mam_result' plugin_attrib = 'mam_result'
#: Available interfaces:
#:
#: - ``queryid``: MAM queryid
#: - ``id``: ID of the result
interfaces = {'queryid', 'id'} interfaces = {'queryid', 'id'}
class Metadata(ElementBase):
"""Element containing archive metadata
.. 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 = 'metadata'
namespace = 'urn:xmpp:mam:2'
plugin_attrib = 'mam_metadata'
class Start(ElementBase):
"""Metadata about the start 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 = 'start'
namespace = 'urn:xmpp:mam:2'
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 = 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

View File

@ -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']

View File

@ -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)

View File

@ -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)

View File

@ -76,3 +76,5 @@ MucRoomItemKeys = Literal[
OptJid = Optional[JID] OptJid = Optional[JID]
JidStr = Union[str, JID] JidStr = Union[str, JID]
OptJidStr = Optional[Union[str, JID]] OptJidStr = Optional[Union[str, JID]]
MAMDefault = Literal['always', 'never', 'roster']

View File

@ -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)

View File

@ -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)