# HG changeset patch # User souliane # Date 1418996622 -3600 # Node ID 3a3e3014f9f89647154dd7ec6c85a4792d116be6 # Parent 56adf73bedebee5f20eca7aee5c9bf0bbe5f9b35 plugin XEP-0313: first draft: - you can already test it with d-feet but it will bug unless you apply the changeset 60dfa2f5d61f which is waiting in the branch "frontends_multi_profiles" (actually just one "assert" to comment in plugin_xep_0085.py) diff -r 56adf73bedeb -r 3a3e3014f9f8 src/plugins/plugin_xep_0313.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/plugins/plugin_xep_0313.py Fri Dec 19 14:43:42 2014 +0100 @@ -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 -r 56adf73bedeb -r 3a3e3014f9f8 src/test/helpers.py --- a/src/test/helpers.py Fri Dec 19 11:42:45 2014 +0100 +++ b/src/test/helpers.py Fri Dec 19 14:43:42 2014 +0100 @@ -25,12 +25,14 @@ 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 @@ 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 -r 56adf73bedeb -r 3a3e3014f9f8 src/test/test_plugin_xep_0313.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/test/test_plugin_xep_0313.py Fri Dec 19 14:43:42 2014 +0100 @@ -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