diff --git a/src/plugins/plugin_xep_0313.py b/src/plugins/plugin_xep_0313.py
new file mode 100644
index 00000000..753ec695
--- /dev/null
+++ b/src/plugins/plugin_xep_0313.py
@@ -0,0 +1,249 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+
+# SAT plugin for Message Archive Management (XEP-0313)
+# Copyright (C) 2009, 2010, 2011, 2012, 2013, 2014 Jérôme Poisson (goffi@goffi.org)
+# Copyright (C) 2013, 2014 Adrien Cossa (souliane@mailoo.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+from sat.core.constants import Const as C
+from sat.core.i18n import _
+from sat.core.log import getLogger
+log = getLogger(__name__)
+
+from wokkel import disco, iwokkel, compat, data_form
+from wokkel.rsm import RSMRequest
+from wokkel.generic import parseXml
+try:
+ from twisted.words.protocols.xmlstream import XMPPHandler
+except ImportError:
+ from wokkel.subprotocols import XMPPHandler
+from twisted.words.xish import domish
+from zope.interface import implements
+
+from dateutil.tz import tzutc
+
+
+NS_MAM = 'urn:xmpp:mam:0'
+NS_SF = 'urn:xmpp:forward:0'
+NS_DD = 'urn:xmpp:delay'
+NS_CLIENT = 'jabber:client'
+
+PLUGIN_INFO = {
+ "name": "Message Archive Management",
+ "import_name": "XEP-0313",
+ "type": "XEP",
+ "protocols": ["XEP-0313"],
+ "dependencies": ["XEP-0059", "XEP-0297", "XEP-0203"],
+ "recommendations": ["XEP-0334"],
+ "main": "XEP_0313",
+ "handler": "yes",
+ "description": _("""Implementation of Message Archive Management""")
+}
+
+
+class XEP_0313(object):
+
+ def __init__(self, host):
+ log.info(_("Message Archive Management plugin initialization"))
+ self.host = host
+ host.bridge.addMethod("MAMqueryFields", ".plugin", in_sign='s', out_sign='s',
+ method=self.queryFields,
+ async=True,
+ doc={})
+ host.bridge.addMethod("MAMqueryArchive", ".plugin", in_sign='ssss', out_sign='s',
+ method=self._queryArchive,
+ async=True,
+ doc={})
+ host.trigger.add("MessageReceived", self.messageReceivedTrigger)
+
+ def getHandler(self, profile):
+ return XEP_0313_handler(self, profile)
+
+ def _queryArchive(self, form=None, rsm=None, node=None, profile_key=C.PROF_KEY_NONE):
+ form_elt = parseXml(form) if form else None
+ rsm_inst = RSMRequest(**rsm) if rsm else None
+ return self.queryArchive(form_elt, rsm_inst, node, profile_key)
+
+ def queryArchive(self, form=None, rsm=None, node=None, profile_key=C.PROF_KEY_NONE):
+ """Query a user, MUC or pubsub archive.
+
+ @param form (domish.Element): data form to filter the request
+ @param rsm (RSMRequest): RSM request instance
+ @param node (unicode): pubsub node to query, or None if inappropriate
+ @param profile_key (unicode): %(doc_profile_key)s
+ @return: a Deferred when the message has been sent
+ """
+ client = self.host.getClient(profile_key)
+ iq = compat.IQ(client.xmlstream, 'set')
+ query_elt = iq.addElement((NS_MAM, 'query'))
+ if form:
+ query_elt.addChild(form)
+ if rsm:
+ rsm.render(query_elt)
+ if node:
+ query_elt['node'] = node
+ d = iq.send()
+
+ def eb(failure):
+ # typically StanzaError with condition u'service-unavailable'
+ log.error(failure.getErrorMessage())
+ return ''
+
+ return d.addCallbacks(lambda elt: elt.toXml(), eb)
+
+ def queryFields(self, profile_key=C.PROF_KEY_NONE):
+ """Ask the server about additional supported fields.
+
+ @param profile_key (unicode): %(doc_profile_key)s
+ @return: the server response as a Deferred domish.Element
+ """
+ # http://xmpp.org/extensions/xep-0313.html#query-form
+ client = self.host.getClient(profile_key)
+ iq = compat.IQ(client.xmlstream, 'get')
+ iq.addElement((NS_MAM, 'query'))
+ d = iq.send()
+
+ def eb(failure):
+ # typically StanzaError with condition u'service-unavailable'
+ log.error(failure.getErrorMessage())
+ return ''
+
+ return d.addCallbacks(lambda elt: elt.toXml(), eb)
+
+ def queryPrefs(self, profile_key=C.PROF_KEY_NONE):
+ """Retrieve the current user preferences.
+
+ @param profile_key (unicode): %(doc_profile_key)s
+ @return: the server response as a Deferred domish.Element
+ """
+ # http://xmpp.org/extensions/xep-0313.html#prefs
+ client = self.host.getClient(profile_key)
+ iq = compat.IQ(client.xmlstream, 'get')
+ iq.addElement((NS_MAM, 'prefs'))
+ d = iq.send()
+
+ def eb(failure):
+ # typically StanzaError with condition u'service-unavailable'
+ log.error(failure.getErrorMessage())
+ return ''
+
+ return d.addCallbacks(lambda elt: elt.toXml(), eb)
+
+ def setPrefs(self, default='roster', always=None, never=None, profile_key=C.PROF_KEY_NONE):
+ """Set news user preferences.
+
+ @param default (unicode): a value in ('always', 'never', 'roster')
+ @param always (list): a list of JID instances
+ @param never (list): a list of JID instances
+ @param profile_key (unicode): %(doc_profile_key)s
+ @return: the server response as a Deferred domish.Element
+ """
+ # http://xmpp.org/extensions/xep-0313.html#prefs
+ assert(default in ('always', 'never', 'roster'))
+ client = self.host.getClient(profile_key)
+ iq = compat.IQ(client.xmlstream, 'set')
+ prefs = iq.addElement((NS_MAM, 'prefs'))
+ prefs['default'] = default
+
+ for var, attr in ((always, 'always'), (never, 'never')):
+ if var is not None:
+ elt = prefs.addElement((None, attr))
+ for entity in var:
+ elt.addElement((None, 'jid')).addContent(entity.full())
+ d = iq.send()
+
+ def eb(failure):
+ # typically StanzaError with condition u'service-unavailable'
+ log.error(failure.getErrorMessage())
+ return ''
+
+ return d.addCallbacks(lambda elt: elt.toXml(), eb)
+
+ @classmethod
+ def datetime2utc(cls, datetime):
+ """Convert a datetime to a XEP-0082 compliant UTC datetime.
+
+ @param datetime (datetime): offset-aware timestamp to convert.
+ @return: unicode
+ """
+ # receipt from wokkel.delay.Delay.toElement
+ stampFormat = '%Y-%m-%dT%H:%M:%SZ'
+ return datetime.astimezone(tzutc()).strftime(stampFormat)
+
+ @classmethod
+ def buildForm(cls, start=None, end=None, with_jid=None, extra=None):
+ """Prepare a data form for MAM query.
+
+ @param start (datetime): offset-aware timestamp to filter out older messages.
+ @param end (datetime): offset-aware timestamp to filter out later messages.
+ @param with_jid (JID): JID against which to match messages.
+ @param extra (list): list of extra fields that are not defined by the
+ specification. Each element must be a 3-tuple containing the field
+ type, name and value.
+ @return: a XEP-0004 data form as domish.Element
+ """
+ form = data_form.Form('submit', formNamespace=NS_MAM)
+ if start:
+ form.addField(data_form.Field('text-single', 'start', XEP_0313.datetime2utc(start)))
+ if end:
+ form.addField(data_form.Field('text-single', 'end', XEP_0313.datetime2utc(end)))
+ if with_jid:
+ form.addField(data_form.Field('jid-single', 'with', with_jid.full()))
+ if extra is not None:
+ for field in extra:
+ form.addField(data_form.Field(*field))
+ return form.toElement()
+
+ def messageReceivedTrigger(self, message, post_treat, profile):
+ """Check if the message is a MAM result. If so, extract the original
+ message, stop processing the current message and process the original
+ message instead.
+ """
+ try:
+ result = domish.generateElementsQNamed(message.elements(), "result", NS_MAM).next()
+ except StopIteration:
+ return True
+ try:
+ forwarded = domish.generateElementsQNamed(result.elements(), "forwarded", NS_SF).next()
+ except StopIteration:
+ log.error(_("MAM result misses its mandatory element!"))
+ return False
+ try:
+ # TODO: delay is not here for nothing, get benefice of it!
+ delay = domish.generateElementsQNamed(forwarded.elements(), "delay", NS_DD).next()
+ msg = domish.generateElementsQNamed(forwarded.elements(), "message", NS_CLIENT).next()
+ except StopIteration:
+ log.error(_(" element misses a mandatory child!"))
+ return False
+ log.debug(_("MAM found a forwarded message"))
+ client = self.host.getClient(profile)
+ client.messageProt.onMessage(msg)
+ return False
+
+
+class XEP_0313_handler(XMPPHandler):
+ implements(iwokkel.IDisco)
+
+ def __init__(self, plugin_parent, profile):
+ self.plugin_parent = plugin_parent
+ self.host = plugin_parent.host
+ self.profile = profile
+
+ def getDiscoInfo(self, requestor, target, nodeIdentifier=''):
+ return [disco.DiscoFeature(NS_MAM)]
+
+ def getDiscoItems(self, requestor, target, nodeIdentifier=''):
+ return []
diff --git a/src/test/helpers.py b/src/test/helpers.py
index ff6116c5..9fcb310f 100644
--- a/src/test/helpers.py
+++ b/src/test/helpers.py
@@ -25,12 +25,14 @@ log_config.satConfigure()
from sat.core import exceptions
from constants import Const as C
from wokkel.xmppim import RosterItem
+from wokkel.generic import parseXml
from sat.core.xmpp import SatRosterProtocol
from sat.memory.memory import Params, Memory
from twisted.trial.unittest import FailTest
from twisted.trial import unittest
from twisted.internet import defer
from twisted.words.protocols.jabber.jid import JID
+from twisted.words.xish import domish
from xml.etree import cElementTree as etree
from collections import Counter
import re
@@ -355,10 +357,25 @@ class FakeXmlStream(object):
self.sent = []
def send(self, obj):
- """Save the sent messages to compare them later"""
+ """Save the sent messages to compare them later.
+
+ @param obj (domish.Element, str or unicode): message to send
+ """
+ if not isinstance(obj, domish.Element):
+ assert(isinstance(obj, str) or isinstance(obj, unicode))
+ obj = parseXml(obj)
+
+ if obj.name == 'iq':
+ # IQ request expects an answer, return the request itself so
+ # you can check if it has been well built by your plugin.
+ self.iqDeferreds[obj['id']].callback(obj)
+
self.sent.append(obj)
return defer.succeed(None)
+ def addObserver(self, *argv):
+ pass
+
class FakeClient(object):
"""Tests involving more than one profile need one instance of this class per profile"""
diff --git a/src/test/test_plugin_xep_0313.py b/src/test/test_plugin_xep_0313.py
new file mode 100644
index 00000000..c5381dad
--- /dev/null
+++ b/src/test/test_plugin_xep_0313.py
@@ -0,0 +1,238 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+
+# SAT: a jabber client
+# Copyright (C) 2009, 2010, 2011, 2012, 2013, 2014 Jérôme Poisson (goffi@goffi.org)
+# Copyright (C) 2013, 2014 Adrien Cossa (souliane@mailoo.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+""" Plugin XEP-0313 """
+
+from constants import Const as C
+from sat.test import helpers
+from sat.plugins.plugin_xep_0313 import XEP_0313
+from twisted.words.protocols.jabber.jid import JID
+from twisted.words.xish import domish
+from dateutil.tz import tzutc
+import datetime
+from wokkel.rsm import RSMRequest
+
+NS_PUBSUB = 'http://jabber.org/protocol/pubsub'
+
+
+class XEP_0313Test(helpers.SatTestCase):
+
+ def setUp(self):
+ self.host = helpers.FakeSAT()
+ self.plugin = XEP_0313(self.host)
+
+ def test_queryArchive(self):
+ xml = """
+
+
+
+ """ % ("H_%d" % domish.Element._idCounter)
+ d = self.plugin.queryArchive(profile_key=C.PROFILE[0])
+ d.addCallback(lambda dummy: self.assertEqualXML(self.host.getSentMessageXml(0), xml, True))
+ return d
+
+ def test_queryArchivePubsub(self):
+ xml = """
+
+
+
+ """ % ("H_%d" % domish.Element._idCounter)
+ d = self.plugin.queryArchive(node="fdp/submitted/capulet.lit/sonnets", profile_key=C.PROFILE[0])
+ d.addCallback(lambda dummy: self.assertEqualXML(self.host.getSentMessageXml(0), xml, True))
+ return d
+
+ def test_queryArchiveWith(self):
+ xml = """
+
+
+
+
+ urn:xmpp:mam:0
+
+
+ juliet@capulet.lit
+
+
+
+
+ """ % ("H_%d" % domish.Element._idCounter)
+ form = self.plugin.buildForm(with_jid=JID('juliet@capulet.lit'))
+ d = self.plugin.queryArchive(form, profile_key=C.PROFILE[0])
+ d.addCallback(lambda dummy: self.assertEqualXML(self.host.getSentMessageXml(0), xml, True))
+ return d
+
+ def test_queryArchiveStartEnd(self):
+ xml = """
+
+
+
+
+ urn:xmpp:mam:0
+
+
+ 2010-06-07T00:00:00Z
+
+
+ 2010-07-07T13:23:54Z
+
+
+
+
+ """ % ("H_%d" % domish.Element._idCounter)
+ start = datetime.datetime(2010, 6, 7, 0, 0, 0, tzinfo=tzutc())
+ end = datetime.datetime(2010, 7, 7, 13, 23, 54, tzinfo=tzutc())
+ form = self.plugin.buildForm(start=start, end=end)
+ d = self.plugin.queryArchive(form, profile_key=C.PROFILE[0])
+ d.addCallback(lambda dummy: self.assertEqualXML(self.host.getSentMessageXml(0), xml, True))
+ return d
+
+ def test_queryArchiveStart(self):
+ xml = """
+
+
+
+
+ urn:xmpp:mam:0
+
+
+ 2010-08-07T00:00:00Z
+
+
+
+
+ """ % ("H_%d" % domish.Element._idCounter)
+ start = datetime.datetime(2010, 8, 7, 0, 0, 0, tzinfo=tzutc())
+ form = self.plugin.buildForm(start=start)
+ d = self.plugin.queryArchive(form, profile_key=C.PROFILE[0])
+ d.addCallback(lambda dummy: self.assertEqualXML(self.host.getSentMessageXml(0), xml, True))
+ return d
+
+ def test_queryArchiveRSM(self):
+ xml = """
+
+
+
+
+ urn:xmpp:mam:0
+
+
+ 2010-08-07T00:00:00Z
+
+
+
+ 10
+
+
+
+ """ % ("H_%d" % domish.Element._idCounter)
+ start = datetime.datetime(2010, 8, 7, 0, 0, 0, tzinfo=tzutc())
+ form = self.plugin.buildForm(start=start)
+ rsm = RSMRequest(max=10)
+ d = self.plugin.queryArchive(form=form, rsm=rsm, profile_key=C.PROFILE[0])
+ d.addCallback(lambda dummy: self.assertEqualXML(self.host.getSentMessageXml(0), xml, True))
+ return d
+
+ def test_queryArchiveRSMPaging(self):
+ xml = """
+
+
+
+ urn:xmpp:mam:0
+ 2010-08-07T00:00:00Z
+
+
+ 10
+ 09af3-cc343-b409f
+
+
+
+ """ % ("H_%d" % domish.Element._idCounter)
+ start = datetime.datetime(2010, 8, 7, 0, 0, 0, tzinfo=tzutc())
+ form = self.plugin.buildForm(start=start)
+ rsm = RSMRequest(max=10, after=u'09af3-cc343-b409f')
+ d = self.plugin.queryArchive(form=form, rsm=rsm, profile_key=C.PROFILE[0])
+ d.addCallback(lambda dummy: self.assertEqualXML(self.host.getSentMessageXml(0), xml, True))
+ return d
+
+ def test_queryFields(self):
+ xml = """
+
+
+
+ """ % ("H_%d" % domish.Element._idCounter)
+ d = self.plugin.queryFields(C.PROFILE[0])
+ d.addCallback(lambda dummy: self.assertEqualXML(self.host.getSentMessageXml(0), xml, True))
+ return d
+
+ def test_queryArchiveFields(self):
+ xml = """
+
+
+
+
+ urn:xmpp:mam:0
+
+
+ Where arth thou, my Juliet?
+
+
+ {http://jabber.org/protocol/mood}mood/lonely
+
+
+
+
+ """ % ("H_%d" % domish.Element._idCounter)
+ extra = (('text-single', 'urn:example:xmpp:free-text-search',
+ 'Where arth thou, my Juliet?'),
+ ('text-single', 'urn:example:xmpp:stanza-content',
+ '{http://jabber.org/protocol/mood}mood/lonely'))
+ form = self.plugin.buildForm(extra=extra)
+ d = self.plugin.queryArchive(form=form, profile_key=C.PROFILE[0])
+ d.addCallback(lambda dummy: self.assertEqualXML(self.host.getSentMessageXml(0), xml, True))
+ return d
+
+ def test_queryPrefs(self):
+ xml = """
+
+
+
+ """ % ("H_%d" % domish.Element._idCounter)
+ d = self.plugin.queryPrefs(profile_key=C.PROFILE[0])
+ d.addCallback(lambda dummy: self.assertEqualXML(self.host.getSentMessageXml(0), xml, True))
+ return d
+
+ def test_setPrefs(self):
+ xml = """
+
+
+
+ romeo@montague.lit
+
+
+ montague@montague.lit
+
+
+
+ """ % ("H_%d" % domish.Element._idCounter)
+ always = [JID('romeo@montague.lit')]
+ never = [JID('montague@montague.lit')]
+ d = self.plugin.setPrefs(always=always, never=never, profile_key=C.PROFILE[0])
+ d.addCallback(lambda dummy: self.assertEqualXML(self.host.getSentMessageXml(0), xml, True))
+ return d