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)
This commit is contained in:
souliane 2014-12-19 14:43:42 +01:00
commit 4a4760b80b
3 changed files with 505 additions and 1 deletions

View file

@ -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 <http://www.gnu.org/licenses/>.
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 <forwarded/> 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(_("<forwarded/> 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 []

View file

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

View file

@ -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 <http://www.gnu.org/licenses/>.
""" 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 = """
<iq type='set' id='%s'>
<query xmlns='urn:xmpp:mam:0'/>
</iq>
""" % ("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 = """
<iq type='set' id='%s'>
<query xmlns='urn:xmpp:mam:0' node='fdp/submitted/capulet.lit/sonnets' />
</iq>
""" % ("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 = """
<iq type='set' id='%s'>
<query xmlns='urn:xmpp:mam:0'>
<x xmlns='jabber:x:data' type='submit'>
<field var='FORM_TYPE' type='hidden'>
<value>urn:xmpp:mam:0</value>
</field>
<field var='with' type='jid-single'>
<value>juliet@capulet.lit</value>
</field>
</x>
</query>
</iq>
""" % ("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 = """
<iq type='set' id='%s'>
<query xmlns='urn:xmpp:mam:0'>
<x xmlns='jabber:x:data' type='submit'>
<field var='FORM_TYPE' type='hidden'>
<value>urn:xmpp:mam:0</value>
</field>
<field var='start' type='text-single'>
<value>2010-06-07T00:00:00Z</value>
</field>
<field var='end' type='text-single'>
<value>2010-07-07T13:23:54Z</value>
</field>
</x>
</query>
</iq>
""" % ("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 = """
<iq type='set' id='%s'>
<query xmlns='urn:xmpp:mam:0'>
<x xmlns='jabber:x:data' type='submit'>
<field var='FORM_TYPE' type='hidden'>
<value>urn:xmpp:mam:0</value>
</field>
<field var='start' type='text-single'>
<value>2010-08-07T00:00:00Z</value>
</field>
</x>
</query>
</iq>
""" % ("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 = """
<iq type='set' id='%s'>
<query xmlns='urn:xmpp:mam:0'>
<x xmlns='jabber:x:data' type='submit'>
<field var='FORM_TYPE' type='hidden'>
<value>urn:xmpp:mam:0</value>
</field>
<field var='start' type='text-single'>
<value>2010-08-07T00:00:00Z</value>
</field>
</x>
<set xmlns='http://jabber.org/protocol/rsm'>
<max>10</max>
</set>
</query>
</iq>
""" % ("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 = """
<iq type='set' id='%s'>
<query xmlns='urn:xmpp:mam:0'>
<x xmlns='jabber:x:data' type='submit'>
<field var='FORM_TYPE' type='hidden'><value>urn:xmpp:mam:0</value></field>
<field var='start' type='text-single'><value>2010-08-07T00:00:00Z</value></field>
</x>
<set xmlns='http://jabber.org/protocol/rsm'>
<max>10</max>
<after>09af3-cc343-b409f</after>
</set>
</query>
</iq>
""" % ("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 = """
<iq type='get' id="%s">
<query xmlns='urn:xmpp:mam:0'/>
</iq>
""" % ("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 = """
<iq type='set' id='%s'>
<query xmlns='urn:xmpp:mam:0'>
<x xmlns='jabber:x:data' type='submit'>
<field type='hidden' var='FORM_TYPE'>
<value>urn:xmpp:mam:0</value>
</field>
<field type='text-single' var='urn:example:xmpp:free-text-search'>
<value>Where arth thou, my Juliet?</value>
</field>
<field type='text-single' var='urn:example:xmpp:stanza-content'>
<value>{http://jabber.org/protocol/mood}mood/lonely</value>
</field>
</x>
</query>
</iq>
""" % ("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 = """
<iq type='get' id='%s'>
<prefs xmlns='urn:xmpp:mam:0'/>
</iq>
""" % ("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 = """
<iq type='set' id='%s'>
<prefs xmlns='urn:xmpp:mam:0' default='roster'>
<always>
<jid>romeo@montague.lit</jid>
</always>
<never>
<jid>montague@montague.lit</jid>
</never>
</prefs>
</iq>
""" % ("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