Changeset 72:727b4d29c48e in ralphm-patches for session_manager.patch


Ignore:
Timestamp:
Jan 27, 2013, 10:40:32 PM (8 years ago)
Author:
Ralph Meijer <ralphm@…>
Branch:
default
Message:

Major reworking of avatars, session manager and stanza handlers.

File:
1 edited

Legend:

Unmodified
Added
Removed
  • session_manager.patch

    r70 r72  
    11# HG changeset patch
    2 # Parent bc450d2e7ed710c5605545e39bb6a054c368571f
     2# Parent fdef0cff7a57368fa21984593ef05e616039e2e2
    33
    4 diff --git a/wokkel/client.py b/wokkel/client.py
    5 --- a/wokkel/client.py
    6 +++ b/wokkel/client.py
    7 @@ -12,19 +12,23 @@
    8  
    9  import base64
    10  
    11 +from zope.interface import implements
    12 +
    13  from twisted.application import service
    14 -from twisted.cred import credentials, error as ecred
    15 +from twisted.cred import credentials, error as ecred, portal
    16  from twisted.internet import defer, reactor
    17 -from twisted.python import log
     4diff --git a/wokkel/ewokkel.py b/wokkel/ewokkel.py
     5new file mode 100644
     6--- /dev/null
     7+++ b/wokkel/ewokkel.py
     8@@ -0,0 +1,32 @@
     9+# Copyright (c) Ralph Meijer.
     10+# See LICENSE for details.
     11+
     12+"""
     13+Exceptions for Wokkel.
     14+"""
     15+
     16+
     17+class WokkelError(Exception):
     18+    """
     19+    Base exception for Wokkel.
     20+    """
     21+
     22+class NoSuchContact(WokkelError):
     23+    """
     24+    Raised when the given contact is not present in the user's roster.
     25+    """
     26+
     27+class NotSubscribed(WokkelError):
     28+    """
     29+    Raised when the contact does not have a presence subscription to the user.
     30+    """
     31+
     32+class NoSuchResource(WokkelError):
     33+    """
     34+    Raised when the given resource is currently not connected.
     35+    """
     36+
     37+class NoSuchUser(WokkelError):
     38+    """
     39+    Raised when there is no user with the given name or JID.
     40+    """
     41diff --git a/wokkel/generic.py b/wokkel/generic.py
     42--- a/wokkel/generic.py
     43+++ b/wokkel/generic.py
     44@@ -7,6 +7,8 @@
     45 Generic XMPP protocol helpers.
     46 """
     47 
     48+import copy
     49+
     50 from zope.interface import implements
     51 
     52 from twisted.internet import defer, protocol
     53@@ -66,6 +68,24 @@
     54     return rootElement
     55 
     56 
     57+def cloneElement(element):
     58+    """
     59+    Make a deep copy of a serialized element.
     60+
     61+    The returned element is an orphaned deep copy of the given original.
     62+
     63+    @note: Since the reference to the original parent, if any, is gone,
     64+    inherited attributes like C{xml:lang} are not preserved.
     65+
     66+    @type element: L{domish.Element}.
     67+    """
     68+    parent = element.parent
     69+    element.parent = None
     70+    clone = copy.deepcopy(element)
     71+    element.parent = parent
     72+    return clone
     73+
     74+
     75 
     76 class FallbackHandler(XMPPHandler):
     77     """
     78@@ -168,10 +188,23 @@
     79     """
     80     Abstract representation of a stanza.
     81 
     82+    @ivar recipient: The receiving entity.
     83+    @type recipient: L{jid.JID}
     84+
     85     @ivar sender: The sending entity.
     86     @type sender: L{jid.JID}
     87-    @ivar recipient: The receiving entity.
     88-    @type recipient: L{jid.JID}
     89+
     90+    @ivar stanzaKind: One of C{'message'}, C{'presence'}, C{'iq'}.
     91+    @type stanzaKind: L{unicode}.
     92+
     93+    @ivar stanzaID: The optional stanza identifier.
     94+    @type stanzaID: L{unicode}.
     95+
     96+    @ivar stanzaType: The optional stanza type.
     97+    @type stanzaType: L{unicode}.
     98+
     99+    @ivar element: The serialized XML of this stanza.
     100+    @type element: L{domish.Element}.
     101     """
     102 
     103     recipient = None
     104@@ -179,6 +212,8 @@
     105     stanzaKind = None
     106     stanzaID = None
     107     stanzaType = None
     108+    element = None
     109+
     110 
     111     def __init__(self, recipient=None, sender=None):
     112         self.recipient = recipient
     113@@ -217,6 +252,7 @@
     114             self.sender = jid.internJID(element['from'])
     115         if element.hasAttribute('to'):
     116             self.recipient = jid.internJID(element['to'])
     117+        self.stanzaKind = element.name
     118         self.stanzaType = element.getAttribute('type')
     119         self.stanzaID = element.getAttribute('id')
     120 
     121@@ -242,6 +278,7 @@
     122 
     123     def toElement(self):
     124         element = domish.Element((None, self.stanzaKind))
     125+        self.element = element
     126         if self.sender is not None:
     127             element['from'] = self.sender.full()
     128         if self.recipient is not None:
     129diff --git a/wokkel/iwokkel.py b/wokkel/iwokkel.py
     130--- a/wokkel/iwokkel.py
     131+++ b/wokkel/iwokkel.py
     132@@ -996,6 +996,14 @@
     133         """)
     134 
     135 
     136+    interested = Attribute(
     137+        """
     138+        This session represents a I{interested resource}, i.e.  the user's
     139+        roster has been requested and roster pushes will be sent out to
     140+        this session.
     141+        """)
     142+
     143+
     144     def loggedIn(realm, mind):
     145         """
     146         Called by the realm when login occurs.
     147diff --git a/wokkel/test/test_generic.py b/wokkel/test/test_generic.py
     148--- a/wokkel/test/test_generic.py
     149+++ b/wokkel/test/test_generic.py
     150@@ -24,6 +24,27 @@
     151 
     152 NS_VERSION = 'jabber:iq:version'
     153 
     154+class CloneElementTest(unittest.TestCase):
     155+    """
     156+    Tests for L{xmppim.clonePresence}.
     157+    """
     158+
     159+    def test_rootElement(self):
     160+        """
     161+        The copied presence stanza is not identical, but renders identically.
     162+        """
     163+        parent = object()
     164+        originalElement = domish.Element((None, 'presence'))
     165+        originalElement.parent = parent
     166+        copyElement = generic.cloneElement(originalElement)
     167+
     168+        self.assertNotIdentical(copyElement, originalElement)
     169+        self.assertEqual(copyElement.toXml(), originalElement.toXml())
     170+        self.assertIdentical(None, copyElement.parent)
     171+        self.assertIdentical(parent, originalElement.parent)
     172+
     173+
     174+
     175 class VersionHandlerTest(unittest.TestCase):
     176     """
     177     Tests for L{wokkel.generic.VersionHandler}.
     178@@ -110,10 +131,21 @@
     179         <message type='chat' from='other@example.org' to='user@example.org'/>
     180         """
     181 
     182-        stanza = generic.Stanza.fromElement(generic.parseXml(xml))
     183+        element = generic.parseXml(xml)
     184+        stanza = generic.Stanza.fromElement(element)
     185         self.assertEqual('chat', stanza.stanzaType)
     186         self.assertEqual(JID('other@example.org'), stanza.sender)
     187         self.assertEqual(JID('user@example.org'), stanza.recipient)
     188+        self.assertIdentical(element, stanza.element)
     189+
     190+
     191+    def test_fromElementStanzaKind(self):
     192+        """
     193+        The stanza kind is also recorded in the stanza.
     194+        """
     195+        xml = """<presence/>"""
     196+        stanza = generic.Stanza.fromElement(generic.parseXml(xml))
     197+        self.assertEqual(u'presence', stanza.stanzaKind)
     198 
     199 
     200     def test_fromElementChildParser(self):
     201diff --git a/wokkel/test/test_xmppim.py b/wokkel/test/test_xmppim.py
     202--- a/wokkel/test/test_xmppim.py
     203+++ b/wokkel/test/test_xmppim.py
     204@@ -5,6 +5,10 @@
     205 Tests for L{wokkel.xmppim}.
     206 """
     207 
     208+from zope.interface import verify
     209+
     210+from twisted.cred import checkers, error as ecred
     211+from twisted.cred.portal import IRealm
     212 from twisted.internet import defer
     213 from twisted.trial import unittest
     214 from twisted.words.protocols.jabber import error
     215@@ -12,9 +16,10 @@
     216 from twisted.words.protocols.jabber.xmlstream import toResponse
     217 from twisted.words.xish import domish, utility
     218 
     219-from wokkel import xmppim
     220+from wokkel import ewokkel, component, xmppim
     221 from wokkel.generic import ErrorStanza, parseXml
     222 from wokkel.test.helpers import TestableRequestHandlerMixin, XmlStreamStub
     223+from wokkel.subprotocols import IQHandlerMixin
     224 
     225 NS_XML = 'http://www.w3.org/XML/1998/namespace'
     226 NS_ROSTER = 'jabber:iq:roster'
     227@@ -99,6 +104,7 @@
     228         self.assertEquals(50, presence.priority)
     229 
     230 
     231+
     232 class PresenceProtocolTest(unittest.TestCase):
     233     """
     234     Tests for L{xmppim.PresenceProtocol}
     235@@ -1418,6 +1424,112 @@
     236 
     237 
     238 
     239+class InMemoryRosterTest(unittest.TestCase):
     240+    """
     241+    Tests for L{xmppim.InMemoryRoster}.
     242+    """
     243+
     244+    def setUp(self):
     245+        contacts = [
     246+            xmppim.RosterItem(JID('contact1@example.org'),
     247+                              subscriptionFrom=True,
     248+                              subscriptionTo=False),
     249+            xmppim.RosterItem(JID('contact2@example.org'),
     250+                              subscriptionFrom=False,
     251+                              subscriptionTo=True),
     252+            ]
     253+        self.roster = xmppim.InMemoryRoster(contacts)
     254+
     255+
     256+    def test_getSubscribers(self):
     257+        def gotSubscribers(subscribers):
     258+            subscribers = list(subscribers)
     259+            self.assertIn(JID('contact1@example.org'), subscribers)
     260+            self.assertNotIn(JID('contact2@example.org'), subscribers)
     261+
     262+
     263+        d = self.roster.getSubscribers()
     264+        d.addCallback(gotSubscribers)
     265+        return d
     266+
     267+
     268+    def test_getSubscriptions(self):
     269+        def gotSubscriptions(subscriptions):
     270+            subscriptions = list(subscriptions)
     271+            self.assertNotIn(JID('contact1@example.org'), subscriptions)
     272+            self.assertIn(JID('contact2@example.org'), subscriptions)
     273+
     274+        d = self.roster.getSubscriptions()
     275+        d.addCallback(gotSubscriptions)
     276+        return d
     277+
     278+
     279+class UserRosterProtocolTest(unittest.TestCase):
     280+    """
     281+    Tests for L{xmppim.UserRosterProtocol}.
     282+    """
     283+
     284+    def setUp(self):
     285+        self.stub = XmlStreamStub()
     286+        self.service = xmppim.UserRosterProtocol()
     287+        self.service.makeConnection(self.stub.xmlstream)
     288+
     289+        entity = JID(u'user@example.org')
     290+        user = xmppim.User(entity)
     291+
     292+        contact = xmppim.RosterItem(JID(u'contact@example.org'))
     293+        user.roster = xmppim.InMemoryRoster([contact])
     294+
     295+        self.session = xmppim.UserSession(user)
     296+        self.stub.xmlstream.avatar = self.session
     297+
     298+
     299+    def test_getRoster(self):
     300+        """
     301+        The returned roster is gotten from the session user.
     302+        """
     303+        def gotRoster(result):
     304+            self.assertIn(JID(u'contact@example.org'), result)
     305+
     306+        request = xmppim.RosterRequest()
     307+        d = self.service.getRoster(request)
     308+        d.addCallback(gotRoster)
     309+        return d
     310+
     311+
     312+    def test_getRosterInterested(self):
     313+        """
     314+        Requesting the roster marks the session as interested in roster pushes.
     315+        """
     316+        def gotRoster(result):
     317+            self.assertTrue(self.session.interested)
     318+
     319+        request = xmppim.RosterRequest()
     320+        d = self.service.getRoster(request)
     321+        d.addCallback(gotRoster)
     322+        return d
     323+
     324+
     325+    def test_handleRequestLocal(self):
     326+        """
     327+        Handle requests without recipient (= local server).
     328+        """
     329+        called = []
     330+        request = xmppim.RosterRequest(recipient=None)
     331+        self.patch(IQHandlerMixin, 'handleRequest', called.append)
     332+        self.service.handleRequest(request.toElement())
     333+        self.assertTrue(called)
     334+
     335+
     336+    def test_handleRequestOther(self):
     337+        """
     338+        If the request has a non-empty recipient, ignore this request.
     339+        """
     340+        request = xmppim.RosterRequest(recipient=JID('other.example.org'))
     341+        self.assertFalse(self.service.handleRequest(request.toElement()))
     342+
     343+
     344+
     345 class MessageTest(unittest.TestCase):
     346     """
     347     Tests for L{xmppim.Message}.
     348@@ -1598,3 +1710,297 @@
     349                          "was deprecated in Wokkel 0.8.0; "
     350                          "please use MessageProtocol.messageReceived instead.",
     351                          warnings[0]['message'])
     352+
     353+
     354+
     355+class UserSessionTest(unittest.TestCase):
     356+    """
     357+    Tests for L{xmppim.UserSession}.
     358+    """
     359+
     360+    def setUp(self):
     361+        self.session = xmppim.UserSession(None)
     362+
     363+
     364+    def test_interface(self):
     365+        """
     366+        UserSession implements IUserSession.
     367+        """
     368+        verify.verifyObject(xmppim.IUserSession, self.session)
     369+
     370+
     371+class UserTest(unittest.TestCase):
     372+    """
     373+    Tests for L{xmppim.User}.
     374+    """
     375+
     376+    def setUp(self):
     377+        self.user = xmppim.User(JID('user@example.org'),
     378+                                xmppim.InMemoryRoster([]))
     379+        self.session = xmppim.UserSession(self.user)
     380+        self.session.bindResource('Home')
     381+
     382+
     383+    @defer.inlineCallbacks
     384+    def test_getPresences(self):
     385+        """
     386+        A contact with a subscription to the user gets its presence.
     387+        """
     388+        contact = xmppim.RosterItem(JID('contact@example.org'),
     389+                                     subscriptionFrom=True)
     390+        self.user.roster.roster[contact.entity] = contact
     391+
     392+        presence = xmppim.AvailabilityPresence()
     393+        self.session.presence = presence
     394+
     395+        presences = yield self.user.getPresences(JID('contact@example.org'))
     396+        self.assertEqual([presence], list(presences))
     397+
     398+        defer.returnValue(None)
     399+
     400+
     401+    def test_getPresencesNoSubscription(self):
     402+        """
     403+        A contact without a subscription raises NotSubscribed.
     404+        """
     405+        contact = xmppim.RosterItem(JID('contact@example.org'),
     406+                                     subscriptionFrom=False)
     407+        self.user.roster.roster[contact.entity] = contact
     408+
     409+        presence = xmppim.AvailabilityPresence()
     410+        self.session.presence = presence
     411+
     412+        d = self.user.getPresences(JID('contact@example.org'))
     413+        self.assertFailure(d, ewokkel.NotSubscribed)
     414+        return d
     415+
     416+
     417+    def test_getPresencesUnknown(self):
     418+        """
     419+        A contact with a subscription to the user gets its presence.
     420+        """
     421+        presence = xmppim.AvailabilityPresence()
     422+        self.session.presence = presence
     423+        d = self.user.getPresences(JID('unknown@example.org'))
     424+        self.assertFailure(d, ewokkel.NoSuchContact)
     425+        return d
     426+
     427+
     428+class AnonymousRealmTest(unittest.TestCase):
     429+    """
     430+    Tests for L{xmppim.AnonymousRealm}.
     431+    """
     432+
     433+    def setUp(self):
     434+        self.realm = xmppim.AnonymousRealm('example.org')
     435+
     436+
     437+    def test_interface(self):
     438+        """
     439+        AnonymousRealm implements IRealm.
     440+        """
     441+        verify.verifyObject(IRealm, self.realm)
     442+
     443+
     444+    def test_requestAvatarAnonymous(self):
     445+        """
     446+        An anonymous avatar ID yields a generated JID.
     447+        """
     448+        def gotAvatar(result):
     449+            def gotUser(user):
     450+                self.assertIdentical(user, avatar.user)
     451+
     452+            iface, avatar, logout = result
     453+            self.assertNotIdentical(None, avatar.user)
     454+            self.assertNotIdentical(None, avatar.user.entity)
     455+            self.assertNotIdentical(None, avatar.user.entity.host)
     456+            self.assertEqual(u'example.org', avatar.user.entity.host)
     457+
     458+            d = self.realm.lookupUser(avatar.user.entity)
     459+            d.addCallback(gotUser)
     460+            return d
     461+
     462+        avatarID = checkers.ANONYMOUS
     463+        d = self.realm.requestAvatar(avatarID, None, xmppim.IUserSession)
     464+        d.addCallback(gotAvatar)
     465+        return d
     466+
     467+
     468+    def test_requestAvatarAnonymousDifferent(self):
     469+        """
     470+        Requesting two anonymous avatar IDs yields different JIDs.
     471+        """
     472+        def gotAvatar1(result):
     473+            iface, avatar1, logout = result
     474+
     475+            def gotAvatar2(result):
     476+                iface, avatar2, logout = result
     477+                self.assertNotIdentical(avatar1, avatar2)
     478+                self.assertNotIdentical(avatar1.user, avatar2.user)
     479+                self.assertNotEqual(avatar1.user.entity,
     480+                                    avatar2.user.entity)
     481+
     482+            d = self.realm.requestAvatar(avatarID, None, xmppim.IUserSession)
     483+            d.addCallback(gotAvatar2)
     484+            return d
     485+
     486+        avatarID = checkers.ANONYMOUS
     487+        d = self.realm.requestAvatar(avatarID, None, xmppim.IUserSession)
     488+        d.addCallback(gotAvatar1)
     489+        return d
     490+
     491+
     492+    def test_logout(self):
     493+        """
     494+        When the logout function is called, the user is removed from the realm.
     495+        """
     496+        def gotAvatar(result):
     497+            iface, avatar, logout = result
     498+
     499+            logout()
     500+
     501+            entity = avatar.user.entity
     502+            d = self.realm.lookupUser(entity)
     503+            self.assertFailure(d, ewokkel.NoSuchUser)
     504+            return d
     505+
     506+        avatarID = checkers.ANONYMOUS
     507+        d = self.realm.requestAvatar(avatarID, None, xmppim.IUserSession)
     508+        d.addCallback(gotAvatar)
     509+        return d
     510+
     511+
     512+
     513+class StaticRealmTest(unittest.TestCase):
     514+    """
     515+    Tests for L{xmppim.StaticRealmTest}.
     516+    """
     517+
     518+    def setUp(self):
     519+        entity = JID(u'\u00e9lise@example.org')
     520+        self.user = xmppim.User(entity)
     521+        users = {entity: self.user}
     522+        self.realm = xmppim.StaticRealm('example.org', users)
     523+
     524+
     525+    def test_interface(self):
     526+        """
     527+        StaticRealm implements IRealm.
     528+        """
     529+        verify.verifyObject(IRealm, self.realm)
     530+
     531+
     532+    def test_requestAvatar(self):
     533+        """
     534+        A UserSession is initialized and returned from requestAvatar.
     535+        """
     536+        def gotAvatar(result):
     537+            iface, avatar, logout = result
     538+            self.assertIdentical(self.user, avatar.user)
     539+
     540+        avatarID = u'\u00e9lise'.encode('utf-8')
     541+        d = self.realm.requestAvatar(avatarID, None, xmppim.IUserSession)
     542+        d.addCallback(gotAvatar)
     543+        return d
     544+
     545+
     546+    def test_requestAvatarUnknown(self):
     547+        """
     548+        A UserSession is initialized and returned from requestAvatar.
     549+        """
     550+        avatarID = u'nobody'.encode('utf-8')
     551+        d = self.realm.requestAvatar(avatarID, None, xmppim.IUserSession)
     552+        self.assertFailure(d, ecred.LoginDenied)
     553+        return d
     554+
     555+
     556+
     557+class TestRouter(component.Router):
     558+    """
     559+    Router that only records incoming traffic for testing.
     560+    """
     561+
     562+    def __init__(self):
     563+        super(TestRouter, self).__init__()
     564+        self.output = []
     565+
     566+
     567+    def route(self, stanza):
     568+        """
     569+        All routed stanzas are recorded.
     570+        """
     571+        self.output.append(stanza)
     572+
     573+
     574+
     575+class SessionManagerTest(unittest.TestCase):
     576+    """
     577+    Tests for L{xmppim.SessionManager}.
     578+    """
     579+
     580+    def setUp(self):
     581+        self.router = TestRouter()
     582+        self.sessionManager = xmppim.SessionManager(self.router,
     583+                                                    u'example.org')
     584+        self.sessionManager.startService()
     585+
     586+        self.input = []
     587+        def onElement(element):
     588+            self.input.append(element)
     589+
     590+        self.sessionManager.xmlstream.addObserver('/*', onElement)
     591+
     592+
     593+    def test_routeOrDeliverLocal(self):
     594+        """
     595+        Stanzas for local domains are reinjected in to the XML stream.
     596+        """
     597+        element = parseXml("""<presence from='test@example.org'
     598+                                        to='other@example.org'/>""")
     599+        self.sessionManager.xmlstream.send(element)
     600+
     601+        self.assertEqual(1, len(self.input))
     602+        self.assertEqual(0, len(self.router.output))
     603+
     604+
     605+    def test_routeOrDeliverRemote(self):
     606+        """
     607+        Stanzas for other domains are sent to the router.
     608+        """
     609+        element = parseXml("""<presence from='test@example.org'
     610+                                        to='other@example.com'/>""")
     611+        self.sessionManager.xmlstream.send(element)
     612+
     613+        self.assertEqual(0, len(self.input))
     614+        self.assertEqual(1, len(self.router.output))
     615+
     616+
     617+    def test_probePresence(self):
     618+        """
     619+        Presence probes are sent to contacts with a subscription.
     620+        """
     621+        def cb(result):
     622+            self.assertEqual(1, len(self.router.output))
     623+            element = self.router.output[-1]
     624+            self.assertEqual(u'presence',
     625+                             element.name)
     626+            self.assertEqual(u'probe',
     627+                             element.getAttribute(u'type'))
     628+            self.assertEqual(u'contact2@example.com',
     629+                             element.getAttribute(u'to'))
     630+
     631+        contacts = [
     632+            xmppim.RosterItem(JID(u'contact1@example.com'),
     633+                              subscriptionFrom=True,
     634+                              subscriptionTo=False),
     635+            xmppim.RosterItem(JID(u'contact2@example.com'),
     636+                              subscriptionFrom=False,
     637+                              subscriptionTo=True),
     638+            ]
     639+        roster = xmppim.InMemoryRoster(contacts)
     640+        entity = JID(u'user@example.org')
     641+        user = xmppim.User(entity, roster)
     642+
     643+        d = self.sessionManager.probePresence(user)
     644+        d.addCallback(cb)
     645+        return d
     646diff --git a/wokkel/xmppim.py b/wokkel/xmppim.py
     647--- a/wokkel/xmppim.py
     648+++ b/wokkel/xmppim.py
     649@@ -12,12 +12,20 @@
     650 
     651 import warnings
     652 
     653+from zope.interface import implementer
     654+
     655+from twisted.cred import error as ecred, portal
     656 from twisted.internet import defer
    18657+from twisted.python import log, randbytes
    19  from twisted.names.srvconnect import SRVConnector
    20  from twisted.words.protocols.jabber import client, error, sasl, xmlstream
    21 -from twisted.words.xish import domish
    22 +from twisted.words.protocols.jabber.jid import JID, internJID
    23 +from twisted.words.xish import domish, utility
    24  
    25  from wokkel import generic
    26  from wokkel.compat import XmlStreamServerFactory
    27  from wokkel.iwokkel import IUserSession
    28  from wokkel.subprotocols import ServerStreamManager
    29  from wokkel.subprotocols import StreamManager
    30 +from wokkel.subprotocols import XMPPHandler
    31  
    32  NS_CLIENT = 'jabber:client'
    33  
    34 @@ -482,3 +486,186 @@
    35          return [
    36              generic.StanzaForwarder()
    37          ]
    38 +
    39 +
    40 +
     658 from twisted.words.protocols.jabber import error
     659 from twisted.words.protocols.jabber.jid import JID
     660 from twisted.words.xish import domish
     661 
     662-from wokkel.generic import ErrorStanza, Stanza, Request
     663+from wokkel.component import InternalComponent
     664+from wokkel.ewokkel import NoSuchContact
     665+from wokkel.ewokkel import NotSubscribed, NoSuchResource, NoSuchUser
     666+from wokkel.iwokkel import IUserSession
     667+from wokkel.generic import ErrorStanza, Stanza, Request, cloneElement
     668 from wokkel.subprotocols import IQHandlerMixin
     669 from wokkel.subprotocols import XMPPHandler
     670 from wokkel.subprotocols import asyncObserver
     671@@ -1062,6 +1070,67 @@
     672 
     673 
     674 
     675+class InMemoryRoster(object):
     676+
     677+    def __init__(self, items):
     678+        self.roster = Roster(((item.entity, item) for item in items))
     679+
     680+
     681+    def getRoster(self, version=None):
     682+        return defer.succeed(self.roster)
     683+
     684+
     685+    def getSubscribers(self):
     686+        subscribers = (entity for entity, item in self.roster.iteritems()
     687+                              if item.subscriptionFrom)
     688+        return defer.succeed(subscribers)
     689+
     690+
     691+    def getSubscriptions(self):
     692+        subscriptions = (entity for entity, item in self.roster.iteritems()
     693+                                if item.subscriptionTo)
     694+        return defer.succeed(subscriptions)
     695+
     696+
     697+    def getContact(self, entity):
     698+        try:
     699+            return defer.succeed(self.roster[entity])
     700+        except KeyError:
     701+            return defer.fail(NoSuchContact())
     702+
     703+
     704+
     705+
     706+
     707+class UserRosterProtocol(RosterServerProtocol):
     708+    """
     709+    Roster protocol handler for client connections.
     710+
     711+    This protocol is meant to be used with
     712+    L{wokkel.client.XMPPC2SServerFactory} to interact with L{UserSession} and
     713+    L{User}.
     714+    """
     715+
     716+    def handleRequest(self, iq):
     717+        """
     718+        Ignore roster requests for non-empty recipients.
     719+        """
     720+        if iq.getAttribute('to'):
     721+            return False
     722+        else:
     723+            return super(UserRosterProtocol, self).handleRequest(iq)
     724+
     725+
     726+    def getRoster(self, request):
     727+        """
     728+        Return the roster of the user associated with this session.
     729+        """
     730+        session = self.xmlstream.avatar
     731+        session.interested = True
     732+        return session.user.roster.getRoster()
     733+
     734+
     735+
     736 class Message(Stanza):
     737     """
     738     A message stanza.
     739@@ -1159,3 +1228,448 @@
     740 
     741             self.onMessage(message.element)
     742             return True
     743+
     744+
     745+
     746+@implementer(IUserSession)
    41747+class UserSession(object):
    42 +
    43 +    implements(IUserSession)
    44 +
    45 +    realm = None
     748+    """
     749+    An XMPP user session.
     750+
     751+    This represents the session for a connected client after authenticating
     752+    as L{user}.
     753+
     754+    @ivar user: The authenticated user for this session.
     755+    @type user: L{User}
     756+
     757+    @ivar mind: The protocol instance for this session.
     758+    @type mind: L{xmlstream.Xmlstream}
     759+
     760+    @ivar entity: The full JID of the entity after a resource has been
     761+       bound.
     762+    @type entity: L{JID}
     763+
     764+    @ivar connected: Flag that is C{True} while a resource is bound.
     765+    @type connected: L{boolean}
     766+
     767+    @ivar interested: Flag to record that the roster has been requested. When
     768+        L{True}, this session will receive roster pushes.
     769+    @type interested: L{boolean}
     770+
     771+    @ivar presence: Last broadcast presence from the client for this session.
     772+    @type presence: L{AvailabilityPresence}
     773+    """
     774+
     775+    user = None
    46776+    mind = None
    47 +
     777+    entity = None
    48778+    connected = False
    49779+    interested = False
    50780+    presence = None
    51781+
    52 +    clientStream = None
    53 +
    54 +    def __init__(self, entity):
    55 +        self.entity = entity
     782+    def __init__(self, user):
     783+        self.user = user
    56784+
    57785+
    58786+    def loggedIn(self, realm, mind):
     787+        self.mind = mind
    59788+        self.realm = realm
    60 +        self.mind = mind
    61789+
    62790+
     
    67795+            return entity
    68796+
    69 +        d = self.realm.bindResource(self, resource)
     797+        d = self.user.bindResource(self, resource)
    70798+        d.addCallback(cb)
    71799+        return d
     
    74802+    def logout(self):
    75803+        self.connected = False
    76 +        self.realm.unbindResource(self)
     804+
     805+        if self.entity:
     806+            self.user.unbindResource(self.entity.resource)
    77807+
    78808+
    79809+    def send(self, element):
    80 +        self.realm.onElement(element, self)
    81 +
    82 +
    83 +    def receive(self, element):
     810+        """
     811+        Called when the client sends a stanza.
     812+        """
     813+        self.realm.server.routeOrDeliver(element)
     814+
     815+
     816+    def receive(self, element, recipient=None):
     817+        """
     818+        Deliver a stanza to the client.
     819+        """
    84820+        self.mind.send(element)
    85821+
    86822+
    87 +
    88 +class SessionManager(XMPPHandler):
     823+    def probePresence(self):
     824+        self.user.probePresence(self)
     825+
     826+
     827+    def broadcastPresence(self, presence, available):
     828+        if available:
     829+            self.presence = presence
     830+        else:
     831+            self.presence = None
     832+            # TODO: unset probeSent on user?
     833+            # TODO: save last unavailable presence?
     834+
     835+        self.user.broadcastPresence(presence)
     836+
     837+
     838+
     839+class User(object):
     840+    """
     841+    An XMPP user account.
     842+
     843+    @ivar entity: The JID of the user.
     844+    @type entity: L{JID}
     845+
     846+    @ivar roster: The user's roster.
     847+
     848+    @ivar sessions: The currently connected sessions for this user, indexed by
     849+        resource.
     850+    @type sessions: L{dict}
     851+
     852+    @ivar probeSent: Flag that is C{True} if presence probes have been sent
     853+        out for this user. This is only done on the initial presence broadcast
     854+        of the first available resource. Subsequent resources will get the
     855+        presences stored in L{contactPresences}.
     856+    @type probeSent: L{boolean}
     857+
     858+    @ivar contactPresences: Cached presences of contacts as a mapping of L{JID}
     859+        to L{AvailabilityPresence}.
     860+    @type contactPresences: L{dict}
     861+
     862+    @ivar realm: The realm that provided this user object.
     863+    @type realm: L{IRealm} provider
     864+    """
     865+
     866+    realm = None
     867+
     868+    def __init__(self, entity, roster=None):
     869+        self.entity = entity
     870+        self.roster = roster
     871+        self.sessions = {}
     872+        self.probeSent = False
     873+        self.contactPresences = {}
     874+
     875+
     876+    def bindResource(self, session, resource):
     877+        if resource is None:
     878+            resource = randbytes.secureRandom(8).encode('hex')
     879+        elif resource in self.sessions:
     880+            resource = resource + ' ' + randbytes.secureRandom(8).encode('hex')
     881+
     882+        entity = JID(tuple=(self.entity.user, self.entity.host, resource))
     883+        self.sessions[resource] = session
     884+
     885+        return defer.succeed(entity)
     886+
     887+
     888+    def unbindResource(self, resource):
     889+        del self.sessions[resource]
     890+        return defer.succeed(None)
     891+
     892+
     893+    def deliverIQ(self, stanza):
     894+        try:
     895+            session = self.sessions[stanza.recipient.resource]
     896+        except KeyError:
     897+            raise NoSuchResource()
     898+
     899+        session.receive(stanza.element)
     900+
     901+
     902+    def deliverMessage(self, stanza):
     903+        if stanza.recipient.resource:
     904+            try:
     905+                session = self.sessions[stanza.recipient.resource]
     906+            except KeyError:
     907+                if stanza.stanzaType in ('normal', 'chat', 'headline'):
     908+                    self.deliverMessageAnyResource(stanza)
     909+                else:
     910+                    raise NoSuchResource()
     911+            else:
     912+                session.receive(stanza.element)
     913+        else:
     914+            if stanza.stanzaType == 'groupchat':
     915+                raise NotImplementedError("Groupchat message to the bare JID")
     916+            else:
     917+                self.deliverMessageAnyResource(stanza)
     918+
     919+
     920+    def deliverMessageAnyResource(self, stanza):
     921+        if stanza.stanzaType == 'headline':
     922+            recipients = set()
     923+            for resource, session in self.sessions.iteritems():
     924+                if session.presence.priority >= 0:
     925+                    recipients.add(resource)
     926+        elif stanza.stanzaType in ('chat', 'normal'):
     927+            priorities = {}
     928+            for resource, session in self.sessions.iteritems():
     929+                if not session.presence or not session.presence.available:
     930+                    continue
     931+                priority = session.presence.priority
     932+                if priority >= 0:
     933+                    priorities.setdefault(priority, set()).add(resource)
     934+            if priorities:
     935+                maxPriority = max(priorities.keys())
     936+                recipients = priorities[maxPriority]
     937+            else:
     938+                # No available resource, offline storage not supported
     939+                raise NotImplementedError("Offline storage is not supported")
     940+        else:
     941+            recipients = set()
     942+
     943+        if recipients:
     944+            for resource in recipients:
     945+                session = self.sessions[resource]
     946+                session.receive(stanza.element)
     947+        else:
     948+            # silently discard
     949+            log.msg("Discarding message to %r" % stanza.recipient)
     950+
     951+
     952+    def deliverPresence(self, stanza):
     953+        if not stanza.recipient.resource:
     954+            # record
     955+
     956+            for session in self.sessions.itervalues():
     957+                if session.presence:
     958+                    session.receive(stanza.element)
     959+
     960+
     961+    def probePresence(self, session):
     962+        """
     963+        Probe presences for this user.
     964+
     965+        If this is the first session requesting presence probes, they are
     966+        sent out to the contacts via the realm. After that, the last received
     967+        presences are sent back to the session directly.
     968+        """
     969+        if not self.probeSent:
     970+            # send out probes
     971+            self.contactPresences = {}
     972+            self.realm.server.probePresence(self)
     973+            self.probeSent = True
     974+        else:
     975+            # deliver known contact presences
     976+            for presence in self.contactPresences.itervalues():
     977+                session.receive(presence.element)
     978+
     979+
     980+    @defer.inlineCallbacks
     981+    def broadcastPresence(self, presence):
     982+        """
     983+        Broadcast presence to all subscribed contacts and myself.
     984+        """
     985+        subscribers = yield self.roster.getSubscribers()
     986+        self.realm.server.multicast(presence, subscribers)
     987+
     988+
     989+    @defer.inlineCallbacks
     990+    def getPresences(self, entity):
     991+        """
     992+        Get presences on behalf of a contact.
     993+
     994+        @param entity: The contact requesting that initiated a presence probe.
     995+        @type entity: L{JID}
     996+
     997+        @return: Deferred that fires with an iterable of
     998+            L{AvailabilityPresence}.
     999+        @rtype: L{defer.Deferred}
     1000+
     1001+        @raise NotSubscribed: If the contact does not have a presence
     1002+            subscription from this user.
     1003+        @raise NoSuchContact: If the requestor is not a contact.
     1004+        """
     1005+        bareEntity = entity.userhostJID()
     1006+        item = yield self.roster.getContact(bareEntity)
     1007+
     1008+        if not item.subscriptionFrom:
     1009+            raise NotSubscribed()
     1010+
     1011+        presences = (session.presence for session in self.sessions.itervalues()
     1012+                                      if session.presence)
     1013+        defer.returnValue(presences)
     1014+        # TODO: send last unavailable or unavailable presence?
     1015+
     1016+
     1017+
     1018+@implementer(portal.IRealm)
     1019+class BaseRealm(object):
     1020+    server = None
     1021+
     1022+    def __init__(self, domain):
     1023+        self.domain = domain
     1024+
     1025+
     1026+    def lookupUser(self, entity):
     1027+        raise NotImplementedError()
     1028+
     1029+
     1030+    def createUser(self, entity):
     1031+        raise NotImplementedError()
     1032+
     1033+
     1034+    def getUser(self, entity):
     1035+        def trapNoSuchUser(failure):
     1036+            failure.trap(NoSuchUser)
     1037+            return self.createUser(entity)
     1038+
     1039+        d = self.lookupUser(entity)
     1040+        d.addErrback(trapNoSuchUser)
     1041+        return d
     1042+
     1043+
     1044+    def logoutFactory(self, session):
     1045+        return session.logout
     1046+
     1047+
     1048+    def entityFromAvatarID(self, avatarId):
     1049+        localpart = avatarId.decode('utf-8')
     1050+        return JID(tuple=(localpart, self.domain, None))
     1051+
     1052+
     1053+    def requestAvatar(self, avatarId, mind, *interfaces):
     1054+        if IUserSession not in interfaces:
     1055+            raise NotImplementedError(self, interfaces)
     1056+
     1057+        entity = self.entityFromAvatarID(avatarId)
     1058+
     1059+        def gotUser(user):
     1060+            session = UserSession(user)
     1061+            session.loggedIn(self, mind)
     1062+            return IUserSession, session, self.logoutFactory(session)
     1063+
     1064+        d = self.getUser(entity)
     1065+        d.addCallback(gotUser)
     1066+        return d
     1067+
     1068+
     1069+
     1070+class AnonymousRealm(BaseRealm):
     1071+
     1072+    def __init__(self, domain):
     1073+        BaseRealm.__init__(self, domain)
     1074+        self.users = {}
     1075+
     1076+
     1077+    def entityFromAvatarID(self, avatarId):
     1078+        localpart = randbytes.secureRandom(8).encode('hex')
     1079+        return JID(tuple=(localpart, self.domain, None))
     1080+
     1081+
     1082+    def lookupUser(self, entity):
     1083+        try:
     1084+            user = self.users[entity]
     1085+        except KeyError:
     1086+            return defer.fail(NoSuchUser(entity))
     1087+        return defer.succeed(user)
     1088+
     1089+
     1090+    def createUser(self, entity):
     1091+        user = User(entity, InMemoryRoster([]))
     1092+        user.realm = self
     1093+        self.users[entity] = user
     1094+        return defer.succeed(user)
     1095+
     1096+
     1097+    def logoutFactory(self, session):
     1098+        def logout():
     1099+            session.logout()
     1100+            del self.users[session.user.entity]
     1101+        return logout
     1102+
     1103+
     1104+
     1105+class StaticRealm(BaseRealm):
     1106+
     1107+    def __init__(self, domain, users):
     1108+        BaseRealm.__init__(self, domain)
     1109+        for user in users.itervalues():
     1110+            user.realm = self
     1111+        self.users = users
     1112+
     1113+
     1114+    def lookupUser(self, entity):
     1115+        try:
     1116+            user = self.users[entity]
     1117+        except KeyError:
     1118+            return defer.fail(NoSuchUser(entity))
     1119+        return defer.succeed(user)
     1120+
     1121+
     1122+    def createUser(self, entity):
     1123+        return defer.fail(ecred.LoginDenied("Can't create a new user"))
     1124+
     1125+
     1126+
     1127+class SessionManager(InternalComponent):
    891128+    """
    901129+    Session Manager.
     
    971136+    """
    981137+
    99 +    implements(portal.IRealm)
    100 +
    101 +    def __init__(self, domain, accounts):
    102 +        XMPPHandler.__init__(self)
    103 +        self.domain = domain
    104 +        self.accounts = accounts
    105 +
    106 +        self.sessions = {}
    107 +        self.clientStream = utility.EventDispatcher()
    108 +        self.clientStream.addObserver('/*', self.routeOrDeliver, -1)
    109 +
    110 +
    111 +    def requestAvatar(self, avatarId, mind, *interfaces):
    112 +        if IUserSession not in interfaces:
    113 +            raise NotImplementedError(self, interfaces)
    114 +
    115 +        localpart = avatarId.decode('utf-8')
    116 +        entity = JID(tuple=(localpart, self.domain, None))
    117 +        session = UserSession(entity)
    118 +        session.loggedIn(self, mind)
    119 +        return IUserSession, session, session.logout
    120 +
    121 +
    122 +    def bindResource(self, session, resource):
    123 +        localpart = session.entity.user
    124 +
    125 +        try:
    126 +            userSessions = self.sessions[localpart]
    127 +        except KeyError:
    128 +            userSessions = self.sessions[localpart] = {}
    129 +
    130 +        if resource is None:
    131 +            resource = randbytes.secureRandom(8).encode('hex')
    132 +        elif resource in self.userSessions:
    133 +            resource = resource + ' ' + randbytes.secureRandom(8).encode('hex')
    134 +
    135 +        entity = JID(tuple=(session.entity.user, session.entity.host, resource))
    136 +        userSessions[resource] = session
    137 +
    138 +        return defer.succeed(entity)
    139 +
    140 +
    141 +    def lookupSessions(self, entity):
    142 +        """
    143 +        Return all sessions for a user.
    144 +
    145 +        @param entity: Entity to retrieve sessions for. This the resource part
    146 +            will be ignored.
    147 +        @type entity: L{JID<twisted.words.protocols.jabber.jid.JID>}
    148 +
    149 +        @return: Mapping of sessions keyed by resource.
    150 +        @rtype: C{dict}
    151 +        """
    152 +        localpart = entity.user
    153 +
    154 +        try:
    155 +            return self.sessions[localpart]
    156 +        except:
    157 +            return {}
    158 +
    159 +
    160 +    def lookupSession(self, entity):
    161 +        """
    162 +        Return the session for a particular resource of an entity.
    163 +
    164 +        @param entity: Entity to retrieve sessions for.
    165 +        @type entity: L{JID<twisted.words.protocols.jabber.jid.JID>}
    166 +
    167 +        @return: C{UserSession}.
    168 +        """
    169 +
    170 +        userSessions = self.lookupSessions(entity)
    171 +        return userSessions[entity.resource]
    172 +
    173 +
    174 +
    175 +    def unbindResource(self, session, reason=None):
    176 +        session.connected = False
    177 +
    178 +        localpart = session.entity.user
    179 +        resource = session.entity.resource
    180 +
    181 +        del self.sessions[localpart][resource]
    182 +        if not self.sessions[localpart]:
    183 +            del self.sessions[localpart]
    184 +
    185 +        return defer.succeed(None)
    186 +
    187 +
    188 +    def onElement(self, element, session):
    189 +        # Make sure each stanza has a sender address
    190 +        if (element.name == 'presence' and
    191 +            element.getAttribute('type') in ('subscribe', 'subscribed',
    192 +                                             'unsubscribe', 'unsubscribed')):
    193 +            element['from'] = session.entity.userhost()
    194 +        else:
    195 +            element['from'] = session.entity.full()
    196 +
    197 +        self.clientStream.dispatch(element)
     1138+    def startService(self):
     1139+        InternalComponent.startService(self)
     1140+        self.xmlstream.send = self.routeOrDeliver
    1981141+
    1991142+
     
    2021145+        Deliver a stanza locally or pass on for routing.
    2031146+        """
    204 +        if element.handled:
    205 +            return
    206 +
    207 +        if (not element.hasAttribute('to') or
    208 +            internJID(element['to']).host == self.domain):
     1147+        if (JID(element['to']).host in self.domains):
    2091148+            # This stanza is for local delivery
    2101149+            log.msg("Delivering locally: %r" % element.toXml())
    211 +            self.xmlstream.dispatch(element)
     1150+            self._pipe.source.dispatch(element)
    2121151+        else:
    2131152+            # This stanza is for remote routing
    2141153+            log.msg("Routing remotely: %r" % element.toXml())
    215 +            XMPPHandler.send(self, element)
    216 +
    217 +
    218 +    def deliverStanza(self, element, recipient):
    219 +        session = self.lookupSession(recipient)
    220 +        session.receive(element)
    221 diff --git a/wokkel/test/test_client.py b/wokkel/test/test_client.py
    222 --- a/wokkel/test/test_client.py
    223 +++ b/wokkel/test/test_client.py
    224 @@ -7,7 +7,7 @@
    225  
    226  from base64 import b64encode
    227  
    228 -from zope.interface import implements
    229 +from zope.interface import implements, verify
    230  
    231  from twisted.cred.portal import IRealm, Portal
    232  from twisted.cred.checkers import InMemoryUsernamePasswordDatabaseDontUse
    233 @@ -601,6 +601,7 @@
    234  
    235  
    236  
    237 +
    238  class XMPPClientListenAuthenticatorTest(unittest.TestCase):
    239      """
    240      Tests for L{client.XMPPClientListenAuthenticator}.
    241 @@ -670,3 +671,26 @@
    242                           "to='example.com' "
    243                           "version='1.0'>")
    244          self.xmlstream.assertStreamError(self, condition='host-unknown')
    245 +
    246 +
    247 +
    248 +class UserSessionTest(unittest.TestCase):
    249 +
    250 +    def setUp(self):
    251 +        self.session = client.UserSession(JID('user@example.org'))
    252 +
    253 +
    254 +    def test_interface(self):
    255 +        verify.verifyObject(client.IUserSession, self.session)
    256 +
    257 +
    258 +
    259 +class SessionManagerTest(unittest.TestCase):
    260 +
    261 +    def setUp(self):
    262 +        accounts = {'user': None}
    263 +        self.sessionManager = client.SessionManager('example.org', accounts)
    264 +
    265 +
    266 +    def test_interface(self):
    267 +        verify.verifyObject(IRealm, self.sessionManager)
     1154+            self._pipe.sink.dispatch(element)
     1155+
     1156+
     1157+    def multicast(self, stanza, recipients):
     1158+        """
     1159+
     1160+        @param stanza: The stanza to send. Its C{element} attribute should
     1161+            already be set.
     1162+        @type stanza: L{wokkel.generic.Stanza}.
     1163+
     1164+        @type recipients: iterable of L{JID}.
     1165+        """
     1166+        if not stanza.element:
     1167+            stanza.toElement()
     1168+
     1169+        for recipient in recipients:
     1170+            clone = cloneElement(stanza.element)
     1171+            clone['to'] = recipient.full()
     1172+            clone.handled = False
     1173+            self.routeOrDeliver(clone)
     1174+
     1175+
     1176+    @defer.inlineCallbacks
     1177+    def probePresence(self, user):
     1178+        """
     1179+        Request the presences of all contacts the user has a subscription to.
     1180+
     1181+        This will send out presence probe stanzas, even to local contacts.
     1182+        """
     1183+        subscriptions = yield user.roster.getSubscriptions()
     1184+        for entity in subscriptions:
     1185+            presence = ProbePresence(recipient=entity,
     1186+                                     sender=user.entity)
     1187+            self.routeOrDeliver(presence.toElement())
Note: See TracChangeset for help on using the changeset viewer.