Changeset 72:727b4d29c48e in ralphm-patches
- Timestamp:
- Jan 27, 2013, 10:40:32 PM (9 years ago)
- Branch:
- default
- Files:
-
- 5 added
- 8 edited
Legend:
- Unmodified
- Added
- Removed
-
c2s_server_factory.patch
r71 r72 1 1 # HG changeset patch 2 # Parent 8cb185c88211ac69faa924f6e93425d102daa2bc2 # Parent 49294b2cf829414b42141731b5130d91474c0443 3 3 Add factory for accepting client connections. 4 4 5 The new XMPPC2SServerFactoryis a server factory for accepting client5 The new `XMPPC2SServerFactory` is a server factory for accepting client 6 6 connections. It uses `XMPPClientListenAuthenticator` to perform the 7 steps for authentication and binding of a resource, and keeps a list 8 of all streams. 9 10 Upon loss of the connection, the service is called with `unbindResource`. 11 Received stanzas cause the service's `onElement` to be called. 12 13 The factory has a `deliverStanza` method to deliver stanzas to a particular 14 recipient. This is used for stanzas that have different recipient addressing 15 than the actual recipient (for presence and messages from a different (or no) 16 resource). 7 steps for authentication and binding of a resource. 8 9 For each connection, the factory also sets up subprotocol handlers by 10 calling `setupHandlers`. By default these are `RecipientAddressStamper` 11 and `StanzaForwarder`. 12 13 The former makes sure that all XML stanzas received from the client 14 are stamped with a proper recipient address. The latter 15 passes stanzas on to the stream's avatar. 17 16 18 17 TODO: 19 18 20 * Add docstrings.21 19 * Add tests. 22 20 … … 24 22 --- a/wokkel/client.py 25 23 +++ b/wokkel/client.py 26 @@ -21,7 +21,9 @@ 27 from twisted.words.xish import domish 24 @@ -22,7 +22,9 @@ 28 25 29 26 from wokkel import generic 30 +from wokkel.compat import XmlStreamServerFactory31 27 from wokkel.iwokkel import IUserSession 32 28 +from wokkel.subprotocols import ServerStreamManager 33 29 from wokkel.subprotocols import StreamManager 30 +from wokkel.subprotocols import XMPPHandler 34 31 35 32 NS_CLIENT = 'jabber:client' 36 @@ -442,3 +444,41 @@ 33 34 @@ -480,3 +482,70 @@ 37 35 self.portal = self.portals[self.xmlstream.thisEntity] 38 36 except KeyError: … … 41 39 + 42 40 + 43 +class XMPPC2SServerFactory(XmlStreamServerFactory): 41 +class RecipientAddressStamper(XMPPHandler): 42 + """ 43 + Protocol handler to ensure client stanzas have a sender address. 44 + """ 45 + 46 + def connectionInitialized(self): 47 + self.xmlstream.addObserver('/*', self.onStanza, priority=1) 48 + 49 + 50 + def onStanza(self, element): 51 + """ 52 + Make sure each stanza has a sender address. 53 + """ 54 + if element.uri: 55 + return 56 + 57 + if (element.name == 'presence' and 58 + element.getAttribute('type') in ('subscribe', 'subscribed', 59 + 'unsubscribe', 'unsubscribed')): 60 + element['from'] = self.xmlstream.avatar.entity.userhost() 61 + elif element.name in ('message', 'presence', 'iq'): 62 + element['from'] = self.xmlstream.avatar.entity.full() 63 + 64 + 65 + 66 +class XMPPC2SServerFactory(xmlstream.XmlStreamServerFactory): 67 + """ 68 + Server factory for XMPP client-server connections. 69 + """ 44 70 + 45 71 + def __init__(self, portals): … … 47 73 + return XMPPClientListenAuthenticator(portals) 48 74 + 49 + XmlStreamServerFactory.__init__(self, authenticatorFactory)75 + xmlstream.XmlStreamServerFactory.__init__(self, authenticatorFactory) 50 76 + self.addBootstrap(xmlstream.STREAM_CONNECTED_EVENT, 51 77 + self.onConnectionMade) … … 74 100 + """ 75 101 + return [ 76 + generic.StanzaForwarder() 77 + ] 102 + generic.StanzaForwarder(), 103 + RecipientAddressStamper(), 104 + ] 78 105 diff --git a/wokkel/generic.py b/wokkel/generic.py 79 106 --- a/wokkel/generic.py 80 107 +++ b/wokkel/generic.py 81 @@ -6 15,3 +615,55 @@82 83 self._initializers = self.getInitializers()84 self._initializeStream()108 @@ -628,3 +628,65 @@ 109 standard full stop. 110 """ 111 return name.encode('idna') 85 112 + 86 113 + … … 108 135 + Called when the stream has been initialized. 109 136 + """ 110 + self.xmlstream.addObserver( 111 + '/*[@xmlns="%s"]' % self.xmlstream.namespace, 112 + self.onStanza, priority=-1) 137 + self.xmlstream.addObserver('/*[@xmlns="%s"]' % 138 + self.xmlstream.namespace, 139 + stripNamespace, priority=1) 140 + self.xmlstream.addObserver('/*', self.onStanza, priority=-1) 141 + 113 142 + 114 143 + … … 117 146 + Called when a stanza element was received. 118 147 + 119 + Unless a stanza has already been handled, or the name of the element is 120 + not one of C{'iq'}, C{'message'}, C{'presence'}, the stanza is passed 121 + on to the avatar's C{send} method. 148 + If this is an XML stanza, and it has not been handled by another 149 + subprotocol handler, the stanza is passed on to the avatar's C{send} 150 + method. 151 + 152 + If there is no recipient address on the stanza, a service-unavailable 153 + is returned instead. 122 154 + """ 123 155 + if element.handled: 124 156 + return 125 157 + 126 + if element.name not in ('iq', 'message', 'presence'): 158 + if (element.name not in ('iq', 'message', 'presence') or 159 + element.uri is not None): 127 160 + return 128 161 + 129 + self.xmlstream.avatar.send(element) 130 + 131 + 132 + def onError(reason): 162 + if not element.getAttribute('to'): 163 + exc = error.StanzaError('service-unavailable') 164 + self.send(exc.toResponse(element)) 165 + else: 166 + self.xmlstream.avatar.send(element) 167 + 168 + 169 + def onError(self, reason): 133 170 + """ 134 171 + Log a stream error. 135 172 + """ 136 173 + log.err(reason, "Stream error") 137 diff --git a/wokkel/subprotocols.py b/wokkel/subprotocols.py 138 --- a/wokkel/subprotocols.py 139 +++ b/wokkel/subprotocols.py 140 @@ -142,10 +142,13 @@ 141 @type timeout: C{int} 142 143 @ivar _reactor: A provider of L{IReactorTime} to track timeouts. 144 + 145 + @cvar __streamCount: Global stream count for distinguishing streams. 174 diff --git a/wokkel/test/test_client.py b/wokkel/test/test_client.py 175 --- a/wokkel/test/test_client.py 176 +++ b/wokkel/test/test_client.py 177 @@ -7,7 +7,7 @@ 178 179 from base64 import b64encode 180 181 -from zope.interface import implements 182 +from zope.interface import implementer 183 184 from twisted.cred.portal import IRealm, Portal 185 from twisted.cred.checkers import InMemoryUsernamePasswordDatabaseDontUse 186 @@ -28,6 +28,8 @@ 187 188 from wokkel import client, iwokkel 189 from wokkel.generic import TestableXmlStream, FeatureListenAuthenticator 190 +from wokkel.generic import parseXml 191 +from wokkel.test.helpers import XmlStreamStub 192 193 class XMPPClientTest(unittest.TestCase): 146 194 """ 147 148 timeout = None 149 _reactor = None 150 + __streamCount = 0 151 152 logTraffic = False 153 154 @@ -195,20 +198,24 @@ 155 and call each handler's C{makeConnection} method with the L{XmlStream} 156 instance. 157 """ 158 - def logDataIn(buf): 159 - log.msg("RECV: %r" % buf) 160 + xs.serial = self.__streamCount 161 + BaseStreamManager.__streamCount += 1 162 163 - def logDataOut(buf): 164 - log.msg("SEND: %r" % buf) 165 166 if self.logTraffic: 167 - xs.rawDataInFn = logDataIn 168 - xs.rawDataOutFn = logDataOut 169 + def logData(direction, data): 170 + log.msg(format="%(direction)s (%(streamID)s): %(data)r", 171 + direction=direction, streamID=xs.serial, data=data) 172 + 173 + log.msg(format="Connection %(streamID)s made", streamID=xs.serial) 174 + xs.rawDataInFn = lambda data: logData("RECV", data) 175 + xs.rawDataOutFn = lambda data: logData("SEND", data) 176 177 xs.addObserver(xmlstream.STREAM_AUTHD_EVENT, 178 self.connectionInitialized) 179 xs.addObserver(xmlstream.STREAM_END_EVENT, 180 self.connectionLost) 181 + 182 self.xmlstream = xs 183 184 for e in list(self): 185 @@ -222,6 +229,9 @@ 186 Send out cached stanzas and call each handler's 187 C{connectionInitialized} method. 188 """ 189 + if self.logTraffic: 190 + log.msg(format="Connection %(streamID)s initialized", 191 + streamID=xs.serial) 192 193 xs.addObserver('/iq[@type="result"]', self._onIQResponse) 194 xs.addObserver('/iq[@type="error"]', self._onIQResponse) 195 @@ -247,6 +257,10 @@ 196 L{XmlStream} anymore and notifies each handler that the connection 197 was lost by calling its C{connectionLost} method. 198 """ 199 + if self.logTraffic: 200 + log.msg(format="Connection %(streamID)s lost", 201 + streamID=self.xmlstream.serial) 202 + 203 self.xmlstream = None 204 self._initialized = False 205 195 @@ -180,8 +182,8 @@ 196 197 198 199 +@implementer(iwokkel.IUserSession) 200 class TestSession(object): 201 - implements(iwokkel.IUserSession) 202 203 def __init__(self, domain, user): 204 self.domain = domain 205 @@ -189,14 +191,14 @@ 206 207 208 def bindResource(self, resource): 209 - return defer.succeed(JID(tuple=(self.user, self.domain, resource))) 210 + self.entity = JID(tuple=(self.user, self.domain, resource)) 211 + return defer.succeed(self.entity) 212 213 214 215 +@implementer(IRealm) 216 class TestRealm(object): 217 218 - implements(IRealm) 219 - 220 logoutCalled = False 221 222 def __init__(self, domain): 223 @@ -679,3 +681,91 @@ 224 "to='example.com' " 225 "version='1.0'>") 226 self.xmlstream.assertStreamError(self, condition='host-unknown') 227 + 228 + 229 + 230 +class RecipientAddressStamperTest(unittest.TestCase): 231 + """ 232 + Tests for L{client.RecipientAddressStamper}. 233 + """ 234 + 235 + 236 + def setUp(self): 237 + self.stub = XmlStreamStub() 238 + self.stub.xmlstream.namespace = '' 239 + avatar = TestSession(u'example.org', u'test') 240 + avatar.bindResource(u'Home') 241 + self.stub.xmlstream.avatar = avatar 242 + 243 + self.protocol = client.RecipientAddressStamper() 244 + self.protocol.makeConnection(self.stub.xmlstream) 245 + self.protocol.connectionInitialized() 246 + 247 + 248 + def test_presence(self): 249 + """ 250 + The from address is set to the full JID on presence stanzas. 251 + """ 252 + xml = """<presence/>""" 253 + element = parseXml(xml) 254 + self.stub.xmlstream.dispatch(element) 255 + self.assertEqual(u'test@example.org/Home', 256 + element.getAttribute('from')) 257 + 258 + 259 + def test_presenceSubscribe(self): 260 + """ 261 + The from address is set to the bare JID on presence subscribe. 262 + """ 263 + xml = """<presence type='subscribe'/>""" 264 + element = parseXml(xml) 265 + self.stub.xmlstream.dispatch(element) 266 + self.assertEqual(u'test@example.org', 267 + element.getAttribute('from')) 268 + 269 + 270 + def test_fromAlreadySet(self): 271 + """ 272 + The from address is overridden if already present. 273 + """ 274 + xml = """<presence from='test@example.org/Work'/>""" 275 + element = parseXml(xml) 276 + self.stub.xmlstream.dispatch(element) 277 + self.assertEqual(u'test@example.org/Home', 278 + element.getAttribute('from')) 279 + 280 + 281 + def test_notHandled(self): 282 + """ 283 + The stanza will not have its 'handled' attribute set to True. 284 + """ 285 + xml = """<presence/>""" 286 + element = parseXml(xml) 287 + self.stub.xmlstream.dispatch(element) 288 + self.assertFalse(element.handled) 289 + 290 + 291 + def test_message(self): 292 + """ 293 + The from address is set to the full JID on message stanzas. 294 + """ 295 + xml = """<message to='other@example.org'> 296 + <body>Hi!</body> 297 + </message>""" 298 + element = parseXml(xml) 299 + self.stub.xmlstream.dispatch(element) 300 + self.assertEqual(u'test@example.org/Home', 301 + element.getAttribute('from')) 302 + 303 + 304 + def test_iq(self): 305 + """ 306 + The from address is set to the full JID on iq stanzas. 307 + """ 308 + xml = """<iq type='get' id='g_1'> 309 + <query xmlns='jabber:iq:version'/> 310 + </iq>""" 311 + element = parseXml(xml) 312 + self.stub.xmlstream.dispatch(element) 313 + self.assertEqual(u'test@example.org/Home', 314 + element.getAttribute('from')) 315 diff --git a/wokkel/test/test_generic.py b/wokkel/test/test_generic.py 316 --- a/wokkel/test/test_generic.py 317 +++ b/wokkel/test/test_generic.py 318 @@ -681,3 +681,105 @@ 319 name = u"example.com." 320 result = generic.prepareIDNName(name) 321 self.assertEqual(b"example.com.", result) 322 + 323 + 324 + 325 +class StanzaForwarderTest(unittest.TestCase): 326 + """ 327 + Tests for L{generic.StanzaForwarder}. 328 + """ 329 + 330 + def setUp(self): 331 + class Avatar(object): 332 + def __init__(self): 333 + self.sent = [] 334 + 335 + def send(self, element): 336 + self.sent.append(element) 337 + 338 + self.stub = XmlStreamStub() 339 + self.avatar = Avatar() 340 + self.protocol = generic.StanzaForwarder() 341 + self.protocol.makeConnection(self.stub.xmlstream) 342 + self.protocol.xmlstream.avatar = self.avatar 343 + self.protocol.xmlstream.namespace = u'jabber:client' 344 + self.protocol.send = self.protocol.xmlstream.send 345 + 346 + 347 + def test_onStanza(self): 348 + """ 349 + An XML stanza is delivered at the stream avatar. 350 + """ 351 + self.protocol.connectionInitialized() 352 + 353 + element = domish.Element((None, u'message')) 354 + element[u'to'] = u'other@example.org' 355 + self.stub.send(element) 356 + 357 + self.assertEqual(1, len(self.avatar.sent)) 358 + self.assertEqual(0, len(self.stub.output)) 359 + 360 + 361 + def test_onStanzaNoRecipient(self): 362 + """ 363 + Stanzas without recipient are rejected. 364 + """ 365 + self.protocol.connectionInitialized() 366 + 367 + element = domish.Element((None, u'message')) 368 + self.stub.send(element) 369 + 370 + self.assertEqual(0, len(self.avatar.sent)) 371 + self.assertEqual(1, len(self.stub.output)) 372 + 373 + 374 + def test_onStanzaClientNamespace(self): 375 + """ 376 + Stanzas with an explicit namespace are delivered. 377 + """ 378 + self.protocol.connectionInitialized() 379 + 380 + element = domish.Element(('jabber:client', u'message')) 381 + element[u'to'] = u'other@example.org' 382 + self.stub.send(element) 383 + 384 + self.assertEqual(1, len(self.avatar.sent)) 385 + self.assertEqual(0, len(self.stub.output)) 386 + 387 + 388 + def test_onStanzaWrongNamespace(self): 389 + """ 390 + If there is no xmlns on the stanza, it should still be delivered. 391 + """ 392 + self.protocol.connectionInitialized() 393 + 394 + element = domish.Element((u'testns', u'message')) 395 + element[u'to'] = u'other@example.org' 396 + self.stub.send(element) 397 + 398 + self.assertEqual(0, len(self.avatar.sent)) 399 + self.assertEqual(0, len(self.stub.output)) 400 + 401 + 402 + def test_onStanzaAlreadyHandled(self): 403 + """ 404 + If the stanza is marked as handled, ignore it. 405 + """ 406 + self.protocol.connectionInitialized() 407 + 408 + element = domish.Element((None, u'message')) 409 + element[u'to'] = u'other@example.org' 410 + element.handled = True 411 + self.stub.send(element) 412 + 413 + self.assertEqual(0, len(self.avatar.sent)) 414 + self.assertEqual(0, len(self.stub.output)) 415 + 416 + 417 + def test_onError(self): 418 + """ 419 + A stream error is logged. 420 + """ 421 + exc = error.StreamError('host-unknown') 422 + self.stub.xmlstream.dispatch(exc, xmlstream.STREAM_ERROR_EVENT) 423 + self.assertEqual(1, len(self.flushLoggedErrors(error.StreamError))) -
c2s_stanza_handlers.patch
r70 r72 1 1 # HG changeset patch 2 # Parent d387959177865c2f964eb013df21986a2f9020e92 # Parent c104dd0a9d3fb840b53661822cf06728a5b23d8f 3 3 Add c2s protocol handlers for iq, message and presence stanzas. 4 4 … … 8 8 * Save last unavailable presence for future probes. 9 9 10 diff --git a/doc/examples/client_service.tac b/doc/examples/client_service.tac 11 new file mode 100644 12 --- /dev/null 13 +++ b/doc/examples/client_service.tac 14 @@ -0,0 +1,82 @@ 15 +from twisted.application import service, strports 16 +from twisted.cred.portal import Portal 17 +from twisted.cred.checkers import InMemoryUsernamePasswordDatabaseDontUse 18 +from twisted.internet import defer 19 + 20 +from wokkel import client, xmppim 21 +from wokkel.component import InternalComponent, Router 22 +from wokkel.generic import FallbackHandler 23 +from wokkel.ping import PingHandler 24 +from wokkel.xmppim import RosterItem 25 + 26 +from twisted.words.protocols.jabber.jid import internJID as JID 27 + 28 +import socket 29 +domain = socket.gethostname() 30 + 31 +ALICE = JID('alice@'+domain) 32 +BOB = JID('bob@'+domain) 33 +CHARLIE = JID('charlie@'+domain) 34 + 35 +roster = { 36 + 'alice': { 37 + BOB: RosterItem(BOB, 38 + subscriptionTo=True, 39 + subscriptionFrom=True, 40 + name='Bob'), 41 + CHARLIE: RosterItem(CHARLIE, 42 + subscriptionTo=True, 43 + subscriptionFrom=True, 44 + name='Charlie', 45 + groups=set(['Friends'])), 46 + }, 47 + 'bob': { 48 + ALICE: RosterItem(ALICE, 49 + subscriptionTo=True, 50 + subscriptionFrom=True, 51 + name='Alice'), 52 + } 53 + } 54 + 55 +accounts = set(roster.keys()) 56 + 57 + 58 +class StaticRoster(xmppim.RosterServerProtocol): 59 + 60 + def __init__(self, roster): 61 + xmppim.RosterServerProtocol.__init__(self) 62 + self.roster = roster 63 + 64 + def getRoster(self, request): 65 + user = request.sender.user 66 + return defer.succeed(self.roster[user].values()) 67 + 68 + 69 + 70 +application = service.Application("Jabber server") 71 + 72 +router = Router() 73 +component = InternalComponent(router, domain) 74 +component.setServiceParent(application) 75 + 76 +sessionManager = client.SessionManager(domain, accounts) 77 +sessionManager.setHandlerParent(component) 78 + 79 +checker = InMemoryUsernamePasswordDatabaseDontUse(alice='secret', 80 + bob='secret') 81 +portal = Portal(sessionManager, (checker,)) 82 +portals = {JID(domain): portal} 83 + 84 +xmppim.AccountIQHandler(sessionManager).setHandlerParent(component) 85 +xmppim.AccountMessageHandler(sessionManager).setHandlerParent(component) 86 +xmppim.PresenceServerHandler(sessionManager, domain, roster).setHandlerParent(component) 87 +FallbackHandler().setHandlerParent(component) 88 +StaticRoster(roster).setHandlerParent(component) 89 +PingHandler().setHandlerParent(component) 90 + 91 +c2sFactory = client.XMPPC2SServerFactory(portals) 92 +c2sFactory.logTraffic = True 93 +c2sService = strports.service('5224', c2sFactory) 94 +c2sService.setServiceParent(application) 95 + 96 +sessionManager.connectionManager = c2sFactory 10 diff --git a/wokkel/client.py b/wokkel/client.py 11 --- a/wokkel/client.py 12 +++ b/wokkel/client.py 13 @@ -20,7 +20,7 @@ 14 from twisted.words.protocols.jabber import client, error, sasl, xmlstream 15 from twisted.words.xish import domish 16 17 -from wokkel import generic 18 +from wokkel import generic, xmppim 19 from wokkel.iwokkel import IUserSession 20 from wokkel.subprotocols import ServerStreamManager 21 from wokkel.subprotocols import StreamManager 22 @@ -548,4 +548,6 @@ 23 return [ 24 generic.StanzaForwarder(), 25 RecipientAddressStamper(), 26 + xmppim.UserSessionPresenceProtocol(), 27 + xmppim.UserRosterProtocol(), 28 ] 97 29 diff --git a/wokkel/test/test_xmppim.py b/wokkel/test/test_xmppim.py 98 30 --- a/wokkel/test/test_xmppim.py 99 31 +++ b/wokkel/test/test_xmppim.py 100 @@ -13,7 +13,7 @@ 32 @@ -5,7 +5,7 @@ 33 Tests for L{wokkel.xmppim}. 34 """ 35 36 -from zope.interface import verify 37 +from zope.interface import implementer, verify 38 39 from twisted.cred import checkers, error as ecred 40 from twisted.cred.portal import IRealm 41 @@ -17,7 +17,7 @@ 101 42 from twisted.words.xish import domish, utility 102 43 103 from wokkel import xmppim44 from wokkel import ewokkel, component, xmppim 104 45 -from wokkel.generic import ErrorStanza, parseXml 105 46 +from wokkel.generic import ErrorStanza, Stanza, parseXml 106 47 from wokkel.test.helpers import TestableRequestHandlerMixin, XmlStreamStub 107 108 NS_XML = 'http://www.w3.org/XML/1998/namespace' 109 @@ -1333,6 +1333,82 @@ 110 111 112 48 from wokkel.subprotocols import IQHandlerMixin 49 50 @@ -2004,3 +2004,419 @@ 51 d = self.sessionManager.probePresence(user) 52 d.addCallback(cb) 53 return d 54 + 55 + 56 + 113 57 +class AccountIQHandlerTest(unittest.TestCase): 114 58 + """ … … 117 61 + 118 62 + def setUp(self): 63 + self.user = xmppim.User(JID(u'user@example.org')) 64 + users = {self.user.entity: self.user} 65 + realm = xmppim.StaticRealm(u'example.org', users) 66 + 119 67 + self.stub = XmlStreamStub() 120 + self.protocol = xmppim.AccountIQHandler( None)68 + self.protocol = xmppim.AccountIQHandler(realm) 121 69 + self.protocol.makeConnection(self.stub.xmlstream) 122 70 + self.protocol.connectionInitialized() 123 71 + 124 72 + 125 + def test_onIQNotUser(self): 126 + """ 127 + IQs to JIDs without local part are ignored. 128 + """ 73 + def test_onIQ(self): 74 + """ 75 + IQ stanzas are observed. 76 + """ 77 + received = [] 78 + self.patch(self.protocol, 'iqReceived', received.append) 79 + 129 80 + xml = """ 130 + <iq to=' example.org'>81 + <iq to='user@example.org/Home' from='contact@example.com/Work'> 131 82 + <query xmlns='jabber:iq:version'/> 132 83 + </iq> … … 136 87 + self.stub.send(iq) 137 88 + 89 + self.assertEqual(1, len(received)) 90 + stanza = received[-1] 91 + self.assertEqual(JID(u'user@example.org/Home'), stanza.recipient) 92 + 93 + 94 + def test_onIQNotUser(self): 95 + """ 96 + IQs to JIDs without local part are ignored. 97 + """ 98 + xml = """ 99 + <iq to='example.org' from='contact@example.com/Work'> 100 + <query xmlns='jabber:iq:version'/> 101 + </iq> 102 + """ 103 + 104 + iq = parseXml(xml) 105 + self.stub.send(iq) 106 + 138 107 + self.assertFalse(getattr(iq, 'handled')) 139 108 + 140 109 + 110 + def test_onIQNoResource(self): 111 + """ 112 + IQs to JIDs without resource are ignored. 113 + """ 114 + xml = """ 115 + <iq to='user@example.org' from='contact@example.com/Work'> 116 + <query xmlns='jabber:iq:version'/> 117 + </iq> 118 + """ 119 + 120 + iq = parseXml(xml) 121 + self.stub.send(iq) 122 + 123 + self.assertFalse(getattr(iq, 'handled')) 124 + 125 + 126 + def test_iqReceivedDelivered(self): 127 + """ 128 + IQs are delivered to the user's deliverIQ method. 129 + """ 130 + received = [] 131 + self.patch(self.user, 'deliverIQ', received.append) 132 + 133 + stanza = Stanza(recipient=JID(u'user@example.org/Home'), 134 + sender=JID(u'contact@example.com/Work')) 135 + stanza.stanzaKind = u'iq' 136 + stanza.stanzaType = u'get' 137 + self.protocol.iqReceived(stanza) 138 + self.assertEqual([stanza], received) 139 + 140 + 141 + def test_iqReceivedNoSuchUser(self): 142 + """ 143 + IQs are delivered to the user's deliverIQ method. 144 + """ 145 + def deliverIQ(stanza): 146 + raise ewokkel.NoSuchUser() 147 + 148 + def cb(error): 149 + self.assertEqual('service-unavailable', error.condition) 150 + 151 + self.patch(self.user, 'deliverIQ', deliverIQ) 152 + 153 + stanza = Stanza(recipient=JID(u'other@example.org/Home'), 154 + sender=JID(u'contact@example.com/Work')) 155 + stanza.stanzaKind = u'iq' 156 + stanza.stanzaType = u'get' 157 + d = self.protocol.iqReceived(stanza) 158 + self.assertFailure(d, error.StanzaError) 159 + d.addCallback(cb) 160 + return d 161 + 162 + 141 163 + 142 164 +class AccountMessageHandlerTest(unittest.TestCase): … … 146 168 + 147 169 + def setUp(self): 170 + self.user = xmppim.User(JID(u'test@example.org')) 171 + users = {self.user.entity: self.user} 172 + realm = xmppim.StaticRealm(u'example.org', users) 173 + 148 174 + self.stub = XmlStreamStub() 149 + self.protocol = xmppim.AccountMessageHandler( None)175 + self.protocol = xmppim.AccountMessageHandler(realm) 150 176 + self.protocol.makeConnection(self.stub.xmlstream) 151 177 + self.protocol.connectionInitialized() 152 178 + 153 179 + 154 + def test_onMessageNotUser(self): 155 + """ 156 + Messages to JIDs without local part are ignored. 157 + """ 180 + def test_messageReceived(self): 181 + """ 182 + Message stanzas are observed. 183 + """ 184 + received = [] 185 + self.patch(self.protocol, 'messageReceived', received.append) 186 + 158 187 + xml = """ 159 188 + <message to='example.org'> … … 165 194 + self.stub.send(message) 166 195 + 196 + self.assertEqual(1, len(received)) 197 + stanza = received[-1] 198 + self.assertEqual(u'Hello', stanza.body) 199 + 200 + 201 + 202 + def test_messageReceivedNotUser(self): 203 + """ 204 + Messages to JIDs without local part are ignored. 205 + 206 + This also tests the observer that is set up to handle message stanzas. 207 + """ 208 + xml = """ 209 + <message to='example.org'> 210 + <body>Hello</body> 211 + </message> 212 + """ 213 + 214 + message = parseXml(xml) 215 + self.stub.send(message) 216 + 167 217 + self.assertFalse(getattr(message, 'handled')) 168 218 + 169 219 + 170 + 171 +class ClonePresenceTest(unittest.TestCase): 172 + """ 173 + Tests for L{xmppim.clonePresence}. 174 + """ 175 + 176 + def test_rootElement(self): 177 + """ 178 + The copied presence stanza is not identical, but renders identically. 179 + """ 180 + originalElement = domish.Element((None, 'presence')) 181 + stanza = Stanza.fromElement(originalElement) 182 + copyElement = xmppim.clonePresence(stanza) 183 + 184 + self.assertNotIdentical(copyElement, originalElement) 185 + self.assertEquals(copyElement.toXml(), originalElement.toXml()) 186 + 187 + 188 + 189 class RosterServerProtocolTest(unittest.TestCase, TestableRequestHandlerMixin): 190 """ 191 Tests for L{xmppim.RosterServerProtocol}. 220 + def test_messageReceivedDelivered(self): 221 + """ 222 + Messages are delivered to the user's deliverMessage method. 223 + """ 224 + received = [] 225 + self.patch(self.user, 'deliverMessage', received.append) 226 + 227 + stanza = xmppim.Message(recipient=JID(u'test@example.org'), 228 + sender=JID(u'other@example.com')) 229 + self.protocol.messageReceived(stanza) 230 + self.assertEqual([stanza], received) 231 + 232 + 233 + def test_messageReceivedNotImplemented(self): 234 + """ 235 + If NotImplementedError is raised, service-available is returned. 236 + """ 237 + def deliverMessage(stanza): 238 + raise NotImplementedError() 239 + 240 + def cb(error): 241 + self.assertEqual('service-unavailable', error.condition) 242 + 243 + self.patch(self.user, 'deliverMessage', deliverMessage) 244 + 245 + stanza = xmppim.Message(recipient=JID(u'test@example.org'), 246 + sender=JID(u'other@example.com')) 247 + d = self.protocol.messageReceived(stanza) 248 + self.assertFailure(d, error.StanzaError) 249 + d.addCallback(cb) 250 + return d 251 + 252 + 253 +@implementer(xmppim.IUserSession) 254 +class FakeUserSession(xmppim.UserSession): 255 + 256 + def __init__(self, *args, **kwargs): 257 + super(FakeUserSession, self).__init__(*args, **kwargs) 258 + self.probePresenceCalls = 0 259 + self.broadcastPresenceCalls = [] 260 + 261 + 262 + def probePresence(self): 263 + self.probePresenceCalls += 1 264 + 265 + 266 + def broadcastPresence(self, presence, available): 267 + self.broadcastPresenceCalls.append((presence, available)) 268 + 269 + 270 + 271 +class FakeUserSessionTest(unittest.TestCase): 272 + """ 273 + Tests for L{FakeUserSessionTest}. 274 + """ 275 + 276 + def test_interface(self): 277 + verify.verifyObject(xmppim.IUserSession, FakeUserSession(None)) 278 + 279 + 280 + 281 +class UserSessionPresenceProtocolTest(unittest.TestCase): 282 + """ 283 + Tests for L{xmppim.UserSessionPresenceProtocol}. 284 + """ 285 + 286 + def setUp(self): 287 + self.stub = XmlStreamStub() 288 + self.protocol = xmppim.UserSessionPresenceProtocol() 289 + self.protocol.makeConnection(self.stub.xmlstream) 290 + 291 + entity = JID(u'user@example.org') 292 + user = xmppim.User(entity) 293 + user.roster = xmppim.InMemoryRoster([]) 294 + 295 + self.session = FakeUserSession(user) 296 + self.stub.xmlstream.avatar = self.session 297 + 298 + 299 + def test_availableReceivedDirected(self): 300 + """ 301 + The directed available stanza is passed for delivery. 302 + """ 303 + stanza = xmppim.AvailabilityPresence( 304 + recipient=JID(u'contact@example.org'), 305 + sender=JID(u'user@example.org'), 306 + available=True) 307 + 308 + self.assertFalse(self.protocol.availableReceived(stanza)) 309 + 310 + 311 + def test_availableReceivedBroadcast(self): 312 + """ 313 + Availabilty is broadcast to contacts and presence is probed. 314 + """ 315 + stanza = xmppim.AvailabilityPresence( 316 + sender=JID(u'user@example.org'), 317 + available=True) 318 + 319 + self.assertTrue(self.protocol.availableReceived(stanza)) 320 + self.assertEqual(1, self.session.probePresenceCalls) 321 + self.assertEqual(1, len(self.session.broadcastPresenceCalls)) 322 + presence, available = self.session.broadcastPresenceCalls[-1] 323 + self.assertIdentical(stanza, presence) 324 + self.assertTrue(available) 325 + 326 + 327 + def test_unavailableReceivedDirected(self): 328 + """ 329 + The directed available stanza is passed for delivery. 330 + """ 331 + stanza = xmppim.AvailabilityPresence( 332 + recipient=JID(u'contact@example.org'), 333 + sender=JID(u'user@example.org'), 334 + available=False) 335 + 336 + self.assertFalse(self.protocol.unavailableReceived(stanza)) 337 + 338 + 339 + def test_unavailableReceivedBroadcast(self): 340 + """ 341 + Unavailabilty is broadcast to contacts. 342 + """ 343 + stanza = xmppim.AvailabilityPresence( 344 + sender=JID(u'user@example.org'), 345 + available=False) 346 + 347 + self.assertTrue(self.protocol.unavailableReceived(stanza)) 348 + self.assertEqual(0, self.session.probePresenceCalls) 349 + self.assertEqual(1, len(self.session.broadcastPresenceCalls)) 350 + presence, available = self.session.broadcastPresenceCalls[-1] 351 + self.assertIdentical(stanza, presence) 352 + self.assertFalse(available) 353 + 354 + 355 + def test_subscribedReceived(self): 356 + """ 357 + The subscription approval stanza is passed for delivery. 358 + """ 359 + stanza = xmppim.SubscriptionPresence( 360 + recipient=JID(u'contact@example.org'), 361 + sender=JID(u'user@example.org')) 362 + stanza.stanzaType = u'subscribed' 363 + 364 + self.assertFalse(self.protocol.unsubscribedReceived(stanza)) 365 + 366 + 367 + def test_subscribedReceivedFullJID(self): 368 + """ 369 + The resource is stripped from full JIDs for subscription approval. 370 + """ 371 + stanza = xmppim.SubscriptionPresence( 372 + recipient=JID(u'contact@example.org/Home'), 373 + sender=JID(u'user@example.org')) 374 + stanza.stanzaType = u'subscribed' 375 + stanza.toElement() 376 + 377 + self.protocol.subscribedReceived(stanza) 378 + self.assertEqual(u'contact@example.org', stanza.element['to']) 379 + 380 + 381 + def test_unsubscribedReceived(self): 382 + """ 383 + The subscription cancellation stanza is passed for delivery. 384 + """ 385 + stanza = xmppim.SubscriptionPresence( 386 + recipient=JID(u'contact@example.org'), 387 + sender=JID(u'user@example.org')) 388 + stanza.stanzaType = u'unsubscribed' 389 + 390 + self.assertFalse(self.protocol.unsubscribedReceived(stanza)) 391 + 392 + 393 + def test_unsubscribedReceivedFullJID(self): 394 + """ 395 + The resource is stripped from full JIDs for subscription cancellation. 396 + """ 397 + stanza = xmppim.SubscriptionPresence( 398 + recipient=JID(u'contact@example.org/Home'), 399 + sender=JID(u'user@example.org')) 400 + stanza.stanzaType = u'unsubscribed' 401 + stanza.toElement() 402 + 403 + self.protocol.unsubscribedReceived(stanza) 404 + self.assertEqual(u'contact@example.org', stanza.element['to']) 405 + 406 + 407 + def test_subscribeReceived(self): 408 + """ 409 + The subscribe request stanza is passed for delivery. 410 + """ 411 + stanza = xmppim.SubscriptionPresence( 412 + recipient=JID(u'contact@example.org'), 413 + sender=JID(u'user@example.org')) 414 + stanza.stanzaType = u'subscribe' 415 + 416 + self.assertFalse(self.protocol.subscribedReceived(stanza)) 417 + 418 + 419 + def test_subscribeReceivedFullJID(self): 420 + """ 421 + The resource is stripped from full JIDs for subscribe requests. 422 + """ 423 + stanza = xmppim.SubscriptionPresence( 424 + recipient=JID(u'contact@example.org/Home'), 425 + sender=JID(u'user@example.org')) 426 + stanza.stanzaType = u'subscribe' 427 + stanza.toElement() 428 + 429 + self.protocol.subscribeReceived(stanza) 430 + self.assertEqual(u'contact@example.org', stanza.element['to']) 431 + 432 + 433 + def test_unsubscribeReceived(self): 434 + """ 435 + The unsubscribe request stanza is passed for delivery. 436 + """ 437 + stanza = xmppim.SubscriptionPresence( 438 + recipient=JID(u'contact@example.org'), 439 + sender=JID(u'user@example.org')) 440 + stanza.stanzaType = u'unsubscribe' 441 + 442 + self.assertFalse(self.protocol.unsubscribeReceived(stanza)) 443 + 444 + 445 + def test_unsubscribeReceivedFullJID(self): 446 + """ 447 + The resource is stripped from full JIDs for unsubscribe requests. 448 + """ 449 + stanza = xmppim.SubscriptionPresence( 450 + recipient=JID(u'contact@example.org/Home'), 451 + sender=JID(u'user@example.org')) 452 + stanza.stanzaType = u'unsubscribe' 453 + stanza.toElement() 454 + 455 + self.protocol.unsubscribeReceived(stanza) 456 + self.assertEqual(u'contact@example.org', stanza.element['to']) 457 + 458 + 459 + def test_probeReceived(self): 460 + """ 461 + The probe stanza is passed for delivery. 462 + """ 463 + stanza = xmppim.ProbePresence( 464 + recipient=JID(u'contact@example.org'), 465 + sender=JID(u'user@example.org')) 466 + 467 + self.assertFalse(self.protocol.probeReceived(stanza)) 468 + 469 + 192 470 diff --git a/wokkel/xmppim.py b/wokkel/xmppim.py 193 471 --- a/wokkel/xmppim.py 194 472 +++ b/wokkel/xmppim.py 195 @@ -12,7 +12,10 @@ 196 197 import warnings 198 199 +import copy 200 + 201 from twisted.internet import defer 202 +from twisted.python import log 203 from twisted.words.protocols.jabber import error 204 from twisted.words.protocols.jabber.jid import JID 205 from twisted.words.xish import domish 206 @@ -408,10 +411,7 @@ 207 208 209 210 - def _onPresence(self, element): 211 - """ 212 - Called when a presence stanza has been received. 213 - """ 473 @@ -417,27 +417,33 @@ 474 475 476 214 477 + def parsePresence(self, element): 215 stanza = Stanza.fromElement(element) 216 217 presenceType = stanza.stanzaType or 'available' 218 @@ -421,14 +421,22 @@ 219 except KeyError: 220 return 221 478 + stanza = Stanza.fromElement(element) 479 + 480 + presenceType = stanza.stanzaType or 'available' 481 + 482 + try: 483 + parser = self.presenceTypeParserMap[presenceType] 484 + except KeyError: 485 + return 486 + 487 + return parser.fromElement(element) 488 + 489 + 490 + @asyncObserver 491 def _onPresence(self, element): 492 """ 493 Called when a presence stanza has been received. 494 """ 495 - stanza = Stanza.fromElement(element) 496 - 497 - presenceType = stanza.stanzaType or 'available' 498 - 499 - try: 500 - parser = self.presenceTypeParserMap[presenceType] 501 - except KeyError: 502 - return 503 - 222 504 - presence = parser.fromElement(element) 223 + return parser.fromElement(element)224 +225 +226 + def _onPresence(self, element):227 + """228 + Called when a presence stanza has been received.229 + """230 505 + presence = self.parsePresence(element) 231 506 + presenceType = presence.stanzaType or 'available' … … 234 509 handler = getattr(self, '%sReceived' % presenceType) 235 510 except AttributeError: 236 return 511 - return 512 + return False 237 513 else: 238 514 - handler(presence) 239 + element.handled = handler(presence) 240 241 242 243 @@ -1035,6 +1043,13 @@ 244 return element 245 246 247 + @classmethod 248 + def fromElement(cls, element): 249 + stanza = super(Message, cls).fromElement(element) 250 + stanza.stanzaType = stanza.stanzaType or 'normal' 251 + return stanza 252 + 253 + 254 255 class MessageProtocol(XMPPHandler): 515 + return handler(presence) 516 517 518 519 @@ -1188,10 +1194,12 @@ 520 L{Message}. 256 521 """ 257 @@ -1067,6 +1082,441 @@ 258 259 260 522 523 + observerPriority = 0 524 stanzaFactory = Message 525 526 def connectionInitialized(self): 527 - self.xmlstream.addObserver("/message", self._onMessage) 528 + self.xmlstream.addObserver("/message", self._onMessage, 529 + self.observerPriority) 530 531 532 @asyncObserver 533 @@ -1673,3 +1681,246 @@ 534 presence = ProbePresence(recipient=entity, 535 sender=user.entity) 536 self.routeOrDeliver(presence.toElement()) 537 + 538 + 539 + 261 540 +class AccountIQHandler(XMPPHandler): 262 541 + 263 + def __init__(self, sessionManager):542 + def __init__(self, realm): 264 543 + XMPPHandler.__init__(self) 265 + self. sessionManager = sessionManager544 + self.realm = realm 266 545 + 267 546 + 268 547 + def connectionMade(self): 269 + self.xmlstream.addObserver('/iq', self.onIQ, 1) 270 + 271 + 272 + def onIQ(self, iq): 273 + """ 274 + Handler for iq stanzas to user accounts' connected resources. 548 + self.xmlstream.addObserver('/iq', self._onIQ, 1) 549 + 550 + 551 + @asyncObserver 552 + def _onIQ(self, element): 553 + stanza = Stanza.fromElement(element) 554 + return self.iqReceived(stanza) 555 + 556 + 557 + def iqReceived(self, iq): 558 + """ 559 + Handler for iq stazas to user accounts' connected resources. 275 560 + 276 561 + If the recipient is a bare JID or there is no associated user, this … … 280 565 + the iq. 281 566 + """ 282 + 283 + if iq.handled: 567 + if not iq.recipient.user: 568 + # This is not for a user. 569 + return False 570 + elif not iq.recipient.resource: 571 + # This might be picked up by another handler. 572 + return False 573 + 574 + def gotUser(user): 575 + user.deliverIQ(iq) 576 + 577 + def notFound(failure): 578 + failure.trap(NoSuchResource, NoSuchUser) 579 + raise error.StanzaError('service-unavailable') 580 + 581 + d = self.realm.lookupUser(iq.recipient.userhostJID()) 582 + d.addCallback(gotUser) 583 + d.addErrback(notFound) 584 + return d 585 + 586 + 587 + 588 +class AccountMessageHandler(MessageProtocol): 589 + 590 + observerPriority = 1 591 + 592 + def __init__(self, realm): 593 + MessageProtocol.__init__(self) 594 + self.realm = realm 595 + 596 + 597 + def messageReceived(self, message): 598 + """ 599 + Handler for message stanzas to user accounts. 600 + """ 601 + if not message.recipient.user: 602 + # This is not for a user. 603 + return False 604 + 605 + def gotUser(user): 606 + user.deliverMessage(message) 607 + 608 + def eb(failure): 609 + failure.trap(NoSuchUser, NoSuchResource, NotImplementedError) 610 + raise error.StanzaError('service-unavailable') 611 + 612 + d = self.realm.lookupUser(message.recipient.userhostJID()) 613 + d.addCallback(gotUser) 614 + d.addErrback(eb) 615 + return d 616 + 617 + 618 + 619 +class AccountPresenceHandler(PresenceProtocol): 620 + 621 + def __init__(self, realm): 622 + PresenceProtocol.__init__(self) 623 + self.realm = realm 624 + 625 + 626 + def _deliverPresence(self, presence): 627 + if not presence.recipient.user: 628 + # This is not for a user. 629 + return False 630 + 631 + def gotUser(user): 632 + user.deliverPresence(presence) 633 + 634 + def notFound(failure): 635 + failure.trap(NoSuchResource, NoSuchUser) 284 636 + return 285 637 + 286 + stanza = Stanza.fromElement(iq) 287 + recipient = stanza.recipient 288 + 289 + if not recipient: 290 + # This stanza doesn't have a recipient, ignore it. 291 + return 292 + elif not recipient.user: 293 + # This is not for an account, ignore it 294 + return 295 + elif recipient.user not in self.sessionManager.accounts: 296 + # This is not a user, ignore it 297 + return 298 + elif not recipient.resource: 299 + # Bare JID at local domain, ignore it 300 + return 301 + 302 + userSessions = self.sessionManager.lookupSessions(recipient) 303 + if recipient.resource in userSessions: 304 + self.sessionManager.deliverStanza(iq, recipient) 305 + else: 306 + # Full JID without connected resource, return error 307 + exc = error.StanzaError('service-unavailable') 308 + if stanza.stanzaType in ('result', 'error'): 309 + log.err(exc, 'Could not deliver IQ response') 310 + else: 311 + self.send(exc.toResponse(iq)) 312 + 313 + iq.handled = True 314 + 315 + 316 + 317 +class AccountMessageHandler(XMPPHandler): 318 + 319 + def __init__(self, sessionManager): 320 + XMPPHandler.__init__(self) 321 + self.sessionManager = sessionManager 322 + 323 + 324 + def connectionMade(self): 325 + self.xmlstream.addObserver('/message', self.onMessage, 1) 326 + 327 + 328 + def onMessage(self, element): 329 + """ 330 + Handler for message stanzas to user accounts. 331 + """ 332 + 333 + if element.handled: 334 + return 335 + 336 + message = Message.fromElement(element) 337 + recipient = message.recipient 338 + stanzaType = message.stanzaType or 'normal' 339 + 340 + try: 341 + if not recipient: 342 + # This stanza doesn't have a recipient, ignore it. 343 + return 344 + if not recipient.user: 345 + # This is not for an account, ignore it 346 + return 347 + elif recipient.user not in self.sessionManager.accounts: 348 + # This is not a user, ignore it 349 + return 350 + elif recipient.resource: 351 + userSessions = self.sessionManager.lookupSessions(recipient) 352 + if recipient.resource in userSessions: 353 + self.sessionManager.deliverStanza(element, recipient) 354 + else: 355 + if stanzaType in ('normal', 'chat', 'headline'): 356 + self.onMessageBareJID(message) 357 + elif stanzaType == 'error': 358 + log.msg("Dropping message to unconnected resource %r" % 359 + recipient.full()) 360 + elif stanzaType == 'groupchat': 361 + raise error.StanzaError('service-unavailable') 362 + else: 363 + self.onMessageBareJID(message) 364 + except error.StanzaError, exc: 365 + if stanzaType == 'error': 366 + log.err(exc, "Undeliverable error") 367 + else: 368 + self.send(exc.toResponse(element)) 369 + 370 + element.handled = True 371 + 372 + 373 + def onMessageBareJID(self, message): 374 + userSessions = self.sessionManager.lookupSessions(message.recipient) 375 + 376 + recipients = set() 377 + 378 + if message.stanzaType == 'headline': 379 + for session in userSessions.itervalues(): 380 + if session.presence.priority >= 0: 381 + recipients.add(session.entity) 382 + elif message.stanzaType in ('chat', 'normal'): 383 + priorities = {} 384 + for session in userSessions.itervalues(): 385 + if not session.presence or not session.presence.available: 386 + continue 387 + priority = session.presence.priority 388 + if priority >= 0: 389 + priorities.setdefault(priority, set()).add(session.entity) 390 + if priorities: 391 + maxPriority = max(priorities.keys()) 392 + recipients.update(priorities[maxPriority]) 393 + elif message.stanzaType == 'groupchat': 394 + raise error.StanzaError('service-unavailable') 395 + 396 + if recipients: 397 + for recipient in recipients: 398 + self.sessionManager.deliverStanza(message.element, recipient) 399 + elif message.stanzaType in ('chat', 'normal'): 400 + raise error.StanzaError('service-unavailable') 401 + else: 402 + # silently discard 403 + log.msg("Discarding message to %r" % message.recipient) 404 + 405 + 406 + 407 + 408 +def clonePresence(presence): 409 + """ 410 + Make a deep copy of a presence stanza. 411 + 412 + The returned presence stanza is an orphaned deep copy of the given 413 + original. 414 + 415 + @note: Since the reference to the original parent, if any, is gone, 416 + inherited attributes like C{xml:lang} are not preserved. 417 + """ 418 + element = presence.element 419 + 420 + parent = element.parent 421 + element.parent = None 422 + newElement = copy.deepcopy(element) 423 + element.parent = parent 424 + return newElement 425 + 426 + 427 + 428 +class PresenceServerHandler(PresenceProtocol): 429 + 430 + def __init__(self, sessionManager, domain, roster): 431 + PresenceProtocol.__init__(self) 432 + self.sessionManager = sessionManager 433 + self.domain = domain 434 + self.roster = roster 435 + self.presences = {} # user -> resource -> presence 436 + self.offlinePresences = {} # user -> presence 437 + self.remotePresences = {} # user -> remote entity -> presence 438 + 439 + self.sessionManager.clientStream.addObserver('/presence', 440 + self._onPresenceOutbound) 441 + 442 + 443 + def _onPresenceOutbound(self, element): 444 + log.msg("Got outbound presence: %r" % element.toXml()) 445 + presence = self.parsePresence(element) 446 + 447 + presenceType = presence.stanzaType or 'available' 448 + method = '%sReceivedOutbound' % presenceType 449 + print method 450 + 451 + try: 452 + handler = getattr(self, method) 453 + except AttributeError: 454 + return 455 + else: 456 + element.handled = handler(presence) 457 + 458 + 459 + def _broadcastToOtherResources(self, presence): 460 + """ 461 + Broadcast presence to other available resources. 462 + """ 463 + fromJID = presence.sender 464 + for otherResource in self.presences[fromJID.user]: 465 + if otherResource == fromJID.resource: 466 + continue 467 + 468 + resourceJID = JID(tuple=(fromJID.user, 469 + fromJID.host, 470 + otherResource)) 471 + outPresence = clonePresence(presence) 472 + outPresence['to'] = resourceJID.full() 473 + self.sessionManager.deliverStanza(outPresence, resourceJID) 474 + 475 + 476 + def _broadcastToContacts(self, presence): 477 + """ 478 + Broadcast presence to subscribed entities. 479 + """ 480 + fromJID = presence.sender 481 + roster = self.roster[fromJID.user] 482 + 483 + for item in roster.itervalues(): 484 + if not item.subscriptionFrom: 485 + continue 486 + 487 + outPresence = clonePresence(presence) 488 + outPresence['to'] = item.entity.full() 489 + 490 + if item.entity.host == self.domain: 491 + # local contact 492 + if item.entity.user in self.presences: 493 + # broadcast to contact's available resources 494 + for itemResource in self.presences[item.entity.user]: 495 + resourceJID = JID(tuple=(item.entity.user, 496 + item.entity.host, 497 + itemResource)) 498 + self.sessionManager.deliverStanza(outPresence, 499 + resourceJID) 500 + else: 501 + # remote contact 502 + self.send(outPresence) 503 + 504 + 505 + def _on_availableBroadcast(self, presence): 506 + fromJID = presence.sender 507 + user, resource = fromJID.user, fromJID.resource 508 + roster = self.roster[user] 509 + 510 + if user not in self.presences: 511 + # initial presence 512 + self.presences[user] = {} 513 + self.remotePresences[user] = {} 514 + 515 + # send out probes 516 + for item in roster.itervalues(): 517 + if item.subscriptionTo and item.entity.host != self.domain: 518 + self.probe(item.entity, fromJID) 519 + else: 520 + if resource not in self.presences[user]: 521 + # initial presence with another available resource 522 + 523 + # send last known presences from remote contacts 524 + remotePresences = self.remotePresences[user] 525 + for entity, remotePresence in remotePresences.iteritems(): 526 + self.sessionManager.deliverStanza(remotePresence.element, 527 + fromJID) 528 + 529 + # send presence to other resources 530 + self._broadcastToOtherResources(presence) 531 + 532 + # Send last known local presences 533 + if user not in self.presences or resource not in self.presences[user]: 534 + for item in roster.itervalues(): 535 + if item.subscriptionTo and \ 536 + item.entity.host == self.domain and \ 537 + item.entity.user in self.presences: 538 + for contactPresence in \ 539 + self.presences[item.entity.user].itervalues(): 540 + outPresence = clonePresence(contactPresence) 541 + outPresence['to'] = fromJID.userhost() 542 + self.sessionManager.deliverStanza(outPresence, fromJID) 543 + 544 + # broadcast presence 545 + self._broadcastToContacts(presence) 546 + 547 + # save presence 548 + self.presences[user][resource] = presence 549 + session = self.sessionManager.lookupSession(fromJID) 550 + session.presence = presence 551 + 552 + return True 553 + 554 + 555 + def _on_availableDirected(self, presence): 556 + self.send(presence.element) 557 + return True 558 + 559 + 560 + def availableReceivedOutbound(self, presence): 561 + if presence.recipient: 562 + return self._on_availableDirected(presence) 563 + else: 564 + return self._on_availableBroadcast(presence) 638 + d = self.realm.lookupUser(presence.recipient.userhostJID()) 639 + d.addCallback(gotUser) 640 + d.addErrback(notFound) 641 + return d 565 642 + 566 643 + 567 644 + def availableReceived(self, presence): 568 + fromJID = presence.sender 569 + toJID = presence.recipient 570 + 571 + if not toJID.user: 572 + # This is not for an account, ignore it. 573 + return False 574 + elif toJID.user not in self.roster: 575 + # This is not for a known account, ignore it. 576 + return False 577 + elif toJID.user not in self.presences: 578 + # No available resource, drop it. 579 + return True 580 + else: 581 + for resource in self.presences[toJID.user]: 582 + resourceJID = JID(tuple=(toJID.user, 583 + toJID.host, 584 + resource)) 585 + self.sessionManager.deliverStanza(presence.element, resourceJID) 586 + self.remotePresences[toJID.user][fromJID] = presence 587 + return True 588 + 589 + 590 + def _on_unavailableBroadcast(self, presence): 591 + fromJID = presence.sender 592 + user, resource = fromJID.user, fromJID.resource 593 + 594 + # broadcast presence 595 + self._broadcastToContacts(presence) 596 + 597 + if user in self.presences: 598 + # send presence to other resources 599 + self._broadcastToOtherResources(presence) 600 + 601 + # update stored presences 602 + if resource in self.presences[user]: 603 + del self.presences[user][resource] 604 + 605 + if not self.presences[user]: 606 + # last resource to become unavailable 607 + del self.presences[user] 608 + 609 + # TODO: save last unavailable presence 610 + 611 + return True 612 + 613 + 614 + def _on_unavailableDirected(self, presence): 615 + self.send(presence.element) 616 + return True 617 + 618 + 619 + def unavailableReceivedOutbound(self, presence): 620 + if presence.recipient: 621 + return self._on_unavailableDirected(presence) 622 + else: 623 + return self._on_unavailableBroadcast(presence) 624 + 625 +# def unavailableReceived(self, presence): 626 + 627 + 628 + def subscribedReceivedOutbound(self, presence): 629 + log.msg("%r subscribed %s to its presence" % (presence.sender, 630 + presence.recipient)) 631 + self.send(presence.element) 632 + return True 645 + return self._deliverPresence(presence) 646 + 647 + 648 + def unavailableReceived(self, presence): 649 + return self._deliverPresence(presence) 633 650 + 634 651 + … … 636 653 + log.msg("%r subscribed %s to its presence" % (presence.sender, 637 654 + presence.recipient)) 638 + 639 + 640 + def unsubscribedReceivedOutbound(self, presence): 641 + log.msg("%r unsubscribed %s from its presence" % (presence.sender, 642 + presence.recipient)) 643 + self.send(presence.element) 644 + return True 655 + return self._deliverPresence(presence) 645 656 + 646 657 + … … 648 659 + log.msg("%r unsubscribed %s from its presence" % (presence.sender, 649 660 + presence.recipient)) 650 + 651 + 652 + def subscribeReceivedOutbound(self, presence): 653 + log.msg("%r requests subscription to %s" % (presence.sender, 654 + presence.recipient)) 655 + self.send(presence.element) 656 + return True 661 + return self._deliverPresence(presence) 657 662 + 658 663 + … … 660 665 + log.msg("%r requests subscription to %s" % (presence.sender, 661 666 + presence.recipient)) 662 + 663 + 664 + def unsubscribeReceivedOutbound(self, presence): 665 + log.msg("%r requests unsubscription from %s" % (presence.sender, 666 + presence.recipient)) 667 + self.send(presence.element) 668 + return True 667 + return self._deliverPresence(presence) 669 668 + 670 669 + … … 672 671 + log.msg("%r requests unsubscription from %s" % (presence.sender, 673 672 + presence.recipient)) 673 + return self._deliverPresence(presence) 674 674 + 675 675 + 676 676 + def probeReceived(self, presence): 677 + fromJID = presence.sender 678 + toJID = presence.recipient 679 + 680 + if toJID.user not in self.roster or \ 681 + fromJID.userhost() not in self.roster[toJID.user] or \ 682 + not self.roster[toJID.user][fromJID.userhost()].subscriptionFrom: 683 + # send unsubscribed 684 + pass 685 + elif toJID.user not in self.presences: 686 + # send last unavailable or nothing 687 + pass 677 + if not presence.recipient.user: 678 + # This is not for a user. 679 + return False 680 + 681 + def gotUser(user): 682 + return user.getPresences(presence.sender.userhostJID()) 683 + 684 + def notSubscribed(failure): 685 + failure.trap(NoSuchUser, NotSubscribed) 686 + response = SubscriptionPresence(recipient=presence.sender, 687 + sender=presence.recipient) 688 + response.stanzaType = 'unsubscribed' 689 + return [response] 690 + 691 + def sendPresences(userPresences): 692 + for userPresence in userPresences: 693 + self.parent.multicast(userPresence, [presence.sender]) 694 + 695 + d = self.realm.lookupUser(presence.recipient.userhostJID()) 696 + d.addCallback(gotUser) 697 + d.addErrback(notSubscribed) 698 + d.addCallback(sendPresences) 699 + return d 700 + 701 + 702 + 703 +class UserSessionPresenceProtocol(PresenceProtocol): 704 + """ 705 + Presence protocol for user client sessions. 706 + """ 707 + 708 + def _stampContactJID(self, presence): 709 + """ 710 + Stamp the contact JID to be a bare JID. 711 + """ 712 + if presence.recipient.resource: 713 + contactJID = presence.recipient.userhostJID() 714 + presence.element['to'] = contactJID.userhost() 715 + 716 + 717 + def availableReceived(self, presence): 718 + if presence.recipient: 719 + # Pass through directed presence. 720 + return False 688 721 + else: 689 + for resourcePresence in self.presences[toJID.user].itervalues(): 690 + outPresence = clonePresence(resourcePresence) 691 + outPresence['to'] = fromJID.userhost() 692 + self.send(outPresence) 693 + 694 + 695 + 696 class RosterServerProtocol(XMPPHandler, IQHandlerMixin): 697 """ 698 XMPP subprotocol handler for the roster, server side. 722 + session = self.xmlstream.avatar 723 + sendProbe = not session.presence 724 + 725 + session.broadcastPresence(presence, available=True) 726 + 727 + if sendProbe: 728 + session.probePresence() 729 + 730 + return True 731 + 732 + 733 + def unavailableReceived(self, presence): 734 + if presence.recipient: 735 + # Pass through directed presence. 736 + return False 737 + else: 738 + session = self.xmlstream.avatar 739 + session.broadcastPresence(presence, available=False) 740 + return True 741 + 742 + 743 + def subscribedReceived(self, presence): 744 + """ 745 + Subscription approval confirmation was received. 746 + """ 747 + self._stampContactJID(presence) 748 + return False 749 + 750 + 751 + def unsubscribedReceived(self, presence): 752 + """ 753 + Unsubscription confirmation was received. 754 + """ 755 + self._stampContactJID(presence) 756 + return False 757 + 758 + 759 + def subscribeReceived(self, presence): 760 + """ 761 + Subscription request was received. 762 + """ 763 + self._stampContactJID(presence) 764 + return False 765 + 766 + 767 + def unsubscribeReceived(self, presence): 768 + """ 769 + Unsubscription request was received. 770 + """ 771 + self._stampContactJID(presence) 772 + return False 773 + 774 + 775 + def probeReceived(self, presence): 776 + """ 777 + Probe presence was received. 778 + """ 779 + return False -
client_listen_authenticator.patch
r66 r72 1 1 # HG changeset patch 2 # Parent a1648553ea06b7f0b38fec775db71ac03782e3d62 # Parent c22caa54600c4f85db2a400c7fbea5497f943aa1 3 3 Add authenticator for accepting XMPP client connections. 4 4 5 The new authenticator XMPPClientListenAuthenticator is to be used together6 with an `XmlStream` created for an incoming XMPP stream. It uses the 7 new initializers for SASL (PLAIN only), resource binding and session 8 establishement.5 The new authenticator XMPPClientListenAuthenticator is to be used 6 together with an `XmlStream` created for an incoming XMPP stream. It 7 uses the new initializers for SASL (PLAIN only), resource binding and 8 session establishement. 9 9 10 10 This authenticator needs at least one Twisted Cred portal to hold the … … 56 56 def __init__(self, jid, password): 57 57 xmlstream.ConnectAuthenticator.__init__(self, jid.host) 58 @@ -186,3 +199,2 46@@58 @@ -186,3 +199,284 @@ 59 59 c = XMPPClientConnector(reactor, domain, factory) 60 60 c.connect() … … 80 80 + """ 81 81 + Stream initializer for SASL authentication, receiving side. 82 + 83 + This authenticator uses L{Twisted Cred<twisted.cred>}, the pluggable 84 + authentication system. As such it takes a 85 + L{Portal<twisted.cred.portal.Portal>} to select authentication mechanisms, 86 + creates a credential object for the selected authentication mechanism and 87 + passes it to the portal to login and acquire an avatar. 88 + 89 + The avatar will be set on the C{avatar} attribute of the 90 + L{xmlstream.XmlStream}. 91 + 92 + Currently, only the C{PLAIN} SASL mechanism is supported. 82 93 + """ 83 94 + 84 95 + required = True 96 + _mechanisms = None 97 + __credentialsMap = { 98 + credentials.IAnonymous: 'ANONYMOUS', 99 + credentials.IUsernamePassword: 'PLAIN', 100 + } 85 101 + 86 102 + def __init__(self, name, xs, portal): … … 92 108 + def getFeatures(self): 93 109 + feature = domish.Element((sasl.NS_XMPP_SASL, 'mechanisms')) 94 + feature.addElement('mechanism', content='PLAIN') 110 + 111 + # Advertise supported SASL mechanisms that have corresponding 112 + # checkers in the Portal. 113 + self._mechanisms = set() 114 + for interface in self.portal.listCredentialsInterfaces(): 115 + try: 116 + mechanism = self.__credentialsMap[interface] 117 + except KeyError: 118 + pass 119 + else: 120 + self._mechanisms.add(mechanism) 121 + feature.addElement('mechanism', content=mechanism) 122 + 95 123 + return [feature] 96 124 + … … 142 170 + 143 171 + 144 + def _credentialsFrom Plain(self, auth):172 + def _credentialsFrom_PLAIN(self, auth): 145 173 + """ 146 174 + Create credentials from the initial response for PLAIN. … … 157 185 + 158 186 + 187 + def _credentialsFrom_ANONYMOUS(self, auth): 188 + """ 189 + Create credentials from the initial response for ANONYMOUS. 190 + """ 191 + return credentials.Anonymous() 192 + 193 + 159 194 + def _doAuth(self, auth): 160 195 + """ 161 196 + Start authentication. 162 197 + """ 163 + if auth.getAttribute('mechanism') != 'PLAIN': 198 + mechanism = auth.getAttribute('mechanism') 199 + 200 + if mechanism not in self._mechanisms: 164 201 + raise InvalidMechanism() 165 202 + 166 + creds = self._credentialsFromPlain(auth)203 + creds = getattr(self, '_credentialsFrom_' + mechanism)(auth) 167 204 + 168 205 + def cb((iface, avatar, logout)): … … 180 217 + """ 181 218 + Stream initializer for resource binding, receiving side. 219 + 220 + Upon a request for resource binding, this will call C{bindResource} on 221 + the stream's avatar. 182 222 + """ 183 223 + … … 282 322 + return [BindReceivingInitializer('bind', self.xmlstream), 283 323 + SessionReceivingInitializer('session', self.xmlstream)] 284 + else:285 + return []286 324 + 287 325 + … … 306 344 --- a/wokkel/generic.py 307 345 +++ b/wokkel/generic.py 308 @@ -46 5,6 +465,7 @@346 @@ -467,6 +467,7 @@ 309 347 310 348 def __init__(self): … … 314 352 315 353 def _onElementFallback(self, element): 316 @@ -55 6,11 +557,12 @@354 @@ -558,11 +559,12 @@ 317 355 318 356 self.xmlstream.send(features) … … 331 369 --- a/wokkel/iwokkel.py 332 370 +++ b/wokkel/iwokkel.py 333 @@ -985,6 +985, 45 @@371 @@ -985,6 +985,55 @@ 334 372 335 373 336 374 337 375 +class IUserSession(Interface): 376 + """ 377 + Interface for a XMPP user client session avatar. 378 + """ 379 + 380 + entity = Attribute( 381 + """ 382 + The JID for this session. 383 + """) 384 + 385 + 338 386 + def loggedIn(realm, mind): 339 387 + """ … … 413 461 class XMPPClientTest(unittest.TestCase): 414 462 """ 415 @@ -1 55,3 +168,505 @@463 @@ -164,3 +177,505 @@ 416 464 self.assertEqual(factory.deferred, d2) 417 465 -
listening-authenticator-stream-features.patch
r66 r72 1 1 # HG changeset patch 2 # Parent 9393ad83138bbe6ab1c5249a6a115b8e561446222 # Parent 840b96390047670c5209195300f902689c18b12f 3 3 Add FeatureListeningAuthenticator. 4 4 … … 9 9 --- a/wokkel/generic.py 10 10 +++ b/wokkel/generic.py 11 @@ -10, 13 +10,13@@11 @@ -10,7 +10,7 @@ 12 12 from zope.interface import implements 13 13 … … 15 15 -from twisted.python import reflect 16 16 +from twisted.python import log, reflect 17 from twisted.python.deprecate import deprecated 18 from twisted.python.versions import Version 17 19 from twisted.words.protocols.jabber import error, jid, xmlstream 18 from twisted.words.protocols.jabber.xmlstream import toResponse 20 @@ -18,7 +18,7 @@ 19 21 from twisted.words.xish import domish, utility 20 22 from twisted.words.xish.xmlstream import BootstrapMixin … … 25 27 26 28 IQ_GET = '/iq[@type="get"]' 27 @@ -2 5,6 +25,8 @@29 @@ -27,6 +27,8 @@ 28 30 NS_VERSION = 'jabber:iq:version' 29 31 VERSION = IQ_GET + '/query[@xmlns="' + NS_VERSION + '"]' … … 34 36 """ 35 37 Parse serialized XML into a DOM structure. 36 @@ -327,3 +329,287 @@ 37 38 def clientConnectionFailed(self, connector, reason): 39 self.deferred.errback(reason) 40 + 41 + 42 + 38 @@ -332,6 +334,290 @@ 39 40 41 43 42 +class TestableXmlStream(xmlstream.XmlStream): 44 43 + """ … … 322 321 + self._initializers = self.getInitializers() 323 322 + self._initializeStream() 323 + 324 + 325 + 326 @deprecated(Version("Wokkel", 0, 8, 0), "unicode.encode('idna')") 327 def prepareIDNName(name): 328 """ 324 329 diff --git a/wokkel/iwokkel.py b/wokkel/iwokkel.py 325 330 --- a/wokkel/iwokkel.py … … 388 393 --- a/wokkel/test/test_generic.py 389 394 +++ b/wokkel/test/test_generic.py 390 @@ - 5,11 +5,16@@391 Tests for L{wokkel.generic}.392 """395 @@ -7,14 +7,19 @@ 396 397 import re 393 398 394 399 +from zope.interface import verify 395 400 + 396 401 +from twisted.internet import defer 402 from twisted.python import deprecate 403 from twisted.python.versions import Version 397 404 +from twisted.test import proto_helpers 398 405 from twisted.trial import unittest 406 from twisted.trial.util import suppress as SUPPRESS 399 407 from twisted.words.xish import domish 400 408 from twisted.words.protocols.jabber.jid import JID … … 406 414 407 415 NS_VERSION = 'jabber:iq:version' 408 @@ -268,3 +273,331 @@ 409 The default is no timeout. 410 """ 411 self.assertIdentical(None, self.request.timeout) 412 + 413 + 414 + 416 @@ -276,6 +281,334 @@ 417 418 419 415 420 +class BaseReceivingInitializerTest(unittest.TestCase): 416 421 + """ … … 738 743 + 739 744 + self.xmlstream.assertStreamError(self, condition='invalid-namespace') 745 + 746 + 747 + 748 class PrepareIDNNameTests(unittest.TestCase): 749 """ 750 Tests for L{wokkel.generic.prepareIDNName}. -
roster_server.patch
r68 r72 1 1 Add server side support for the roster protocol. 2 2 3 * Implements roster get by calling `getRoster` and using the returned `list`4 of `RosterItem`sto send back the roster.3 * Implements roster get by calling `getRoster` and using the returned 4 `Roster` to send back the roster. 5 5 6 6 TODO: 7 * Add more docstrings.8 * Use `dict` instead of `list` as return value of `getRoster`.9 7 * Add support for roster sets? 10 8 … … 12 10 --- a/wokkel/test/test_xmppim.py 13 11 +++ b/wokkel/test/test_xmppim.py 14 @@ -1330,3 +1330,81 @@ 15 d = self.handleRequest(xml) 16 d.addCallback(cb) 17 return d 18 + 19 + 20 + 12 @@ -1339,6 +1339,85 @@ 13 14 15 21 16 +class RosterServerProtocolTest(unittest.TestCase, TestableRequestHandlerMixin): 22 17 + """ … … 45 40 + item = xmppim.RosterItem(JID('other@example.org'), True, False, 46 41 + 'The User') 47 + return defer.succeed([item]) 42 + roster = xmppim.Roster({item.entity: item}) 43 + return defer.succeed(roster) 48 44 + 49 45 + def cb(element): … … 94 90 + self.assertFailure(d, NotImplementedError) 95 91 + return d 92 + 93 + 94 + 95 class MessageTest(unittest.TestCase): 96 """ 97 Tests for L{xmppim.Message}. 96 98 diff --git a/wokkel/xmppim.py b/wokkel/xmppim.py 97 99 --- a/wokkel/xmppim.py 98 100 +++ b/wokkel/xmppim.py 99 @@ -2 5,6 +25,7 @@101 @@ -26,6 +26,7 @@ 100 102 NS_ROSTER = 'jabber:iq:roster' 101 103 … … 105 107 106 108 107 @@ -1063,3 +1064,40 @@ 108 """ 109 Called when a message stanza was received. 110 """ 111 + 112 + 113 + 109 @@ -739,9 +740,9 @@ 110 111 112 @classmethod 113 - def fromElement(Class, element): 114 + def fromElement(cls, element): 115 entity = JID(element['jid']) 116 - item = Class(entity) 117 + item = cls(entity) 118 subscription = element.getAttribute('subscription') 119 if subscription == 'remove': 120 item.remove = True 121 @@ -780,21 +781,26 @@ 122 version = None 123 rosterSet = False 124 125 + 126 def parseRequest(self, element): 127 - self.version = element.getAttribute('ver') 128 - 129 - for child in element.elements(NS_ROSTER, 'item'): 130 - self.item = RosterItem.fromElement(child) 131 + roster = Roster.fromElement(element) 132 + self.version = roster.version 133 + for item in roster.itervalues(): 134 + self.item = item 135 break 136 + return roster 137 138 139 def toElement(self): 140 element = Request.toElement(self) 141 - query = element.addElement((NS_ROSTER, 'query')) 142 - if self.version is not None: 143 - query['ver'] = self.version 144 + 145 + roster = Roster() 146 + roster.version = self.version 147 if self.item: 148 - query.addChild(self.item.toElement(rosterSet=self.rosterSet)) 149 + roster[self.item.entity] = self.item 150 + 151 + element.addChild(roster.toElement(rosterSet=self.rosterSet)) 152 + 153 return element 154 155 156 @@ -812,7 +818,7 @@ 157 158 class Roster(dict): 159 """ 160 - In-memory roster container. 161 + Roster container. 162 163 This provides a roster as a mapping from L{JID} to L{RosterItem}. If 164 roster versioning is used, the C{version} attribute holds the version 165 @@ -825,6 +831,28 @@ 166 version = None 167 168 169 + @classmethod 170 + def fromElement(cls, element): 171 + roster = cls() 172 + roster.version = element.getAttribute('ver') 173 + for element in element.elements(NS_ROSTER, 'item'): 174 + item = RosterItem.fromElement(element) 175 + roster[item.entity] = item 176 + return roster 177 + 178 + 179 + def toElement(self, rosterSet=False): 180 + element = domish.Element((NS_ROSTER, 'query')) 181 + 182 + if self.version: 183 + element['ver'] = self.version 184 + 185 + for item in self.itervalues(): 186 + element.addChild(item.toElement(rosterSet)) 187 + 188 + return element 189 + 190 + 191 192 class RosterClientProtocol(XMPPHandler, IQHandlerMixin): 193 """ 194 @@ -898,12 +926,7 @@ 195 196 def processRoster(result): 197 if result.query is not None: 198 - roster = Roster() 199 - roster.version = result.query.getAttribute('ver') 200 - for element in result.query.elements(NS_ROSTER, 'item'): 201 - item = RosterItem.fromElement(element) 202 - roster[item.entity] = item 203 - return roster 204 + return Roster.fromElement(result.query) 205 else: 206 return None 207 208 @@ -1002,6 +1025,43 @@ 209 210 211 114 212 +class RosterServerProtocol(XMPPHandler, IQHandlerMixin): 115 213 + """ … … 127 225 + 128 226 + 129 + def _toRosterReply(self, roster, request):130 + response = domish.Element((NS_ROSTER, 'query'))131 +132 + for item in roster:133 + response.addChild(item.toElement())134 +135 + return response136 +137 +138 227 + def _onRosterGet(self, iq): 139 + request = Stanza.fromElement(iq) 228 + request = RosterRequest.fromElement(iq) 229 + 230 + def toResponse(roster): 231 + return roster.toElement() 140 232 + 141 233 + d = self.getRoster(request) 142 + d.addCallback( self._toRosterReply, request)234 + d.addCallback(toResponse) 143 235 + return d 144 236 + 145 237 + 146 238 + def getRoster(self, request): 239 + """ 240 + Called when the roster is requested. 241 + 242 + @returns: Deferred that fires with a L{Roster}. 243 + @rtype: L{defer.Deferred} 244 + """ 147 245 + raise NotImplementedError() 246 + 247 + 248 + 249 class Message(Stanza): 250 """ 251 A message stanza. -
series
r68 r72 1 deprecate_prepareIDNName.patch 2 async-observer.patch 3 message-stanza.patch 1 4 server-stream-manager.patch 2 roster_server.patch #+c2s 5 stream-manager-logging.patch 3 6 4 7 listening-authenticator-stream-features.patch #+c2s 5 8 client_listen_authenticator.patch #+c2s 9 c2s_server_factory.patch #+c2s 6 10 7 c2s_server_factory.patch #+c2s11 roster_server.patch #+c2s 8 12 session_manager.patch #+c2s 13 14 9 15 c2s_stanza_handlers.patch #+c2s 16 c2s_example.patch 10 17 11 18 version.patch -
server-stream-manager.patch
r68 r72 1 1 # HG changeset patch 2 # Parent f98da30301ea95ae9fe688df4128f6fbdf25df7d 2 # Parent 3f3fe954b1975c2d9115e0fa8177ae7b28a708a8 3 Generalize StreamManager and add ServerStreamManager. 4 5 This generalizes `StreamManager` to `BaseStreamManager` to take the 6 common functionality and reuse it for `ServerStreamManager`. Where 7 `StreamManager` is used for initiating connections, with a factory as 8 a parameter, `ServerStreamManager` works for receiving connections. It 9 must be created in a protocol factory when a connection is established, 10 and then its `makeConnection` should be called to hook it up to the 11 `XmlStream` instance. 3 12 4 13 diff --git a/wokkel/client.py b/wokkel/client.py -
session_manager.patch
r70 r72 1 1 # HG changeset patch 2 # Parent bc450d2e7ed710c5605545e39bb6a054c368571f2 # Parent fdef0cff7a57368fa21984593ef05e616039e2e2 3 3 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 4 diff --git a/wokkel/ewokkel.py b/wokkel/ewokkel.py 5 new 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 + """ 41 diff --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: 129 diff --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. 147 diff --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): 201 diff --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 646 diff --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 18 657 +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) 41 747 +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 46 776 + mind = None 47 + 777 + entity = None 48 778 + connected = False 49 779 + interested = False 50 780 + presence = None 51 781 + 52 + clientStream = None 53 + 54 + def __init__(self, entity): 55 + self.entity = entity 782 + def __init__(self, user): 783 + self.user = user 56 784 + 57 785 + 58 786 + def loggedIn(self, realm, mind): 787 + self.mind = mind 59 788 + self.realm = realm 60 + self.mind = mind61 789 + 62 790 + … … 67 795 + return entity 68 796 + 69 + d = self. realm.bindResource(self, resource)797 + d = self.user.bindResource(self, resource) 70 798 + d.addCallback(cb) 71 799 + return d … … 74 802 + def logout(self): 75 803 + self.connected = False 76 + self.realm.unbindResource(self) 804 + 805 + if self.entity: 806 + self.user.unbindResource(self.entity.resource) 77 807 + 78 808 + 79 809 + 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 + """ 84 820 + self.mind.send(element) 85 821 + 86 822 + 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): 89 1128 + """ 90 1129 + Session Manager. … … 97 1136 + """ 98 1137 + 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 198 1141 + 199 1142 + … … 202 1145 + Deliver a stanza locally or pass on for routing. 203 1146 + """ 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): 209 1148 + # This stanza is for local delivery 210 1149 + log.msg("Delivering locally: %r" % element.toXml()) 211 + self. xmlstream.dispatch(element)1150 + self._pipe.source.dispatch(element) 212 1151 + else: 213 1152 + # This stanza is for remote routing 214 1153 + 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.