source: ralphm-patches/roster_item.patch @ 57:0d8b6cf41728

Last change on this file since 57:0d8b6cf41728 was 57:0d8b6cf41728, checked in by Ralph Meijer <ralphm@…>, 9 years ago

Wokkel 0.7.0 release, clean up various patches.

File size: 22.2 KB
  • wokkel/im.py

    Clean up of RosterItem and RosterClientProtocol.
    
    `RosterItem`:
     * Renamed attributes `jid` and `ask` to `entity` and
       `pendingOut` respectively.
     * Can represent roster items to be removed or that have been removed.
     * Now has `fromElement` and `toElement` methods.
    
    `RosterClientProtocol`:
    
     * Roster returned from `getRoster` is now indexed by `JID`s (instead of
       the `unicode` representation of the JID.
     * `onRosterSet` and `onRosterRemove` can raise `RosterPushIgnored` to
       return a `service-unavailable` stanza error.
    
    diff -r a3c50205821b wokkel/im.py
    a b  
    77XMPP IM protocol support.
    88
    99This module provides generic implementations for the protocols defined in
    10 U{RFC 3921<http://xmpp.org/rfcs/rfc3921.html>} (XMPP IM).
    11 
    12 All of it should eventually move to Twisted.
     10U{RFC 6121<http://www.xmpp.org/rfcs/rfc6121.html>} (XMPP IM).
    1311"""
    1412
     13from twisted.internet import defer
     14from twisted.words.protocols.jabber import error
    1515from twisted.words.protocols.jabber import jid
    1616from twisted.words.xish import domish
    1717
    1818from wokkel.compat import IQ
    1919from wokkel.generic import ErrorStanza, Stanza
     20from wokkel.subprotocols import IQHandlerMixin
    2021from wokkel.subprotocols import XMPPHandler
    2122
    2223NS_XML = 'http://www.w3.org/XML/1998/namespace'
    2324NS_ROSTER = 'jabber:iq:roster'
    2425
     26XPATH_ROSTER_SET = "/iq[@type='set']/query[@xmlns='%s']" % NS_ROSTER
     27
     28
     29
    2530class BasePresence(Stanza):
    2631    """
    2732    Stanza of kind presence.
     
    377382
    378383    This represents one contact from an XMPP contact list known as roster.
    379384
    380     @ivar jid: The JID of the contact.
    381     @type jid: L{jid.JID}
     385    @ivar entity: The JID of the contact.
     386    @type entity: L{jid.JID}
    382387    @ivar name: The optional associated nickname for this contact.
    383388    @type name: C{unicode}
    384389    @ivar subscriptionTo: Subscription state to contact's presence. If C{True},
     
    388393    @ivar subscriptionFrom: Contact's subscription state. If C{True}, the
    389394                            contact is subscribed to the presence information
    390395                            of the roster owner.
    391     @type subscriptionTo: C{bool}
    392     @ivar ask: Whether subscription is pending.
    393     @type ask: C{bool}
     396    @type subscriptionFrom: C{bool}
     397    @ivar pendingOut: Whether the subscription request to this contact is
     398        pending.
     399    @type pendingOut: C{bool}
    394400    @ivar groups: Set of groups this contact is categorized in. Groups are
    395401                  represented by an opaque identifier of type C{unicode}.
    396402    @type groups: C{set}
    397403    """
    398404
    399     def __init__(self, jid):
    400         self.jid = jid
    401         self.name = None
    402         self.subscriptionTo = False
    403         self.subscriptionFrom = False
    404         self.ask = None
    405         self.groups = set()
     405    __subscriptionStates = {(False, False): None,
     406                            (True, False): 'to',
     407                            (False, True): 'from',
     408                            (True, True): 'both'}
    406409
     410    def __init__(self, entity, subscriptionTo=False, subscriptionFrom=False,
     411                       name=None, groups=None):
     412        self.entity = entity
     413        self.subscriptionTo = subscriptionTo
     414        self.subscriptionFrom = subscriptionFrom
     415        self.name = name
     416        self.groups = groups or set()
    407417
     418        self.pendingOut = False
     419        self.approved = False
     420        self.remove = False
    408421
    409 class RosterClientProtocol(XMPPHandler):
     422
     423    def toElement(self):
     424        element = domish.Element((NS_ROSTER, 'item'))
     425        element['jid'] = self.entity.full()
     426
     427        if self.remove:
     428            subscription = 'remove'
     429        else:
     430            subscription = self.__subscriptionStates[self.subscriptionTo,
     431                                                     self.subscriptionFrom]
     432
     433            if self.pendingOut:
     434                element['ask'] = u'subscribe'
     435
     436            if self.name:
     437                element['name'] = self.name
     438
     439            if self.approved:
     440                element['approved'] = u'true'
     441
     442            if self.groups:
     443                for group in self.groups:
     444                    element.addElement('group', content=group)
     445
     446        if subscription:
     447            element['subscription'] = subscription
     448
     449        return element
     450
     451
     452    @classmethod
     453    def fromElement(Class, element):
     454        entity = jid.internJID(element['jid'])
     455        item = Class(entity)
     456        subscription = element.getAttribute('subscription')
     457        if subscription == 'remove':
     458            item.remove = True
     459        else:
     460            item.name = element.getAttribute('name')
     461            item.subscriptionTo = subscription in ('to', 'both')
     462            item.subscriptionFrom = subscription in ('from', 'both')
     463            item.pendingOut = element.getAttribute('ask') == 'subscribe'
     464            item.approved = element.getAttribute('approved') in ('true', '1')
     465            for subElement in domish.generateElementsQNamed(element.children,
     466                                                            'group', NS_ROSTER):
     467                item.groups.add(unicode(subElement))
     468        return item
     469
     470
     471
     472class RosterPushIgnored(Exception):
     473    """
     474    Raised when this entity doesn't want to accept/trust a roster push.
     475    """
     476
     477
     478
     479class RosterClientProtocol(XMPPHandler, IQHandlerMixin):
    410480    """
    411481    Client side XMPP roster protocol.
    412482    """
    413483
     484    iqHandlers = {XPATH_ROSTER_SET: "_onRosterSet"}
     485
     486
    414487    def connectionInitialized(self):
    415         ROSTER_SET = "/iq[@type='set']/query[@xmlns='%s']" % NS_ROSTER
    416         self.xmlstream.addObserver(ROSTER_SET, self._onRosterSet)
    417 
    418 
    419     def _parseRosterItem(self, element):
    420         entity = jid.internJID(element['jid'])
    421         item = RosterItem(entity)
    422         item.name = element.getAttribute('name')
    423         subscription = element.getAttribute('subscription')
    424         item.subscriptionTo = subscription in ('to', 'both')
    425         item.subscriptionFrom = subscription in ('from', 'both')
    426         item.ask = element.getAttribute('ask') == 'subscribe'
    427         for subElement in domish.generateElementsQNamed(element.children,
    428                                                         'group', NS_ROSTER):
    429             item.groups.add(unicode(subElement))
    430 
    431         return item
     488        self.xmlstream.addObserver(XPATH_ROSTER_SET, self.handleRequest)
    432489
    433490
    434491    def getRoster(self):
     
    443500            roster = {}
    444501            for element in domish.generateElementsQNamed(result.query.children,
    445502                                                         'item', NS_ROSTER):
    446                 item = self._parseRosterItem(element)
    447                 roster[item.jid.userhost()] = item
     503                item = RosterItem.fromElement(element)
     504                roster[item.entity] = item
    448505
    449506            return roster
    450507
     
    465522        """
    466523        iq = IQ(self.xmlstream, 'set')
    467524        iq.addElement((NS_ROSTER, 'query'))
    468         item = iq.query.addElement('item')
    469         item['jid'] = entity.full()
    470         item['subscription'] = 'remove'
     525        item = RosterItem(entity)
     526        item.remove = True
     527        iq.query.addChild(item.toElement())
    471528        return iq.send()
    472529
    473530
    474531    def _onRosterSet(self, iq):
    475         if iq.handled or \
    476            iq.hasAttribute('from') and iq['from'] != self.xmlstream:
    477             return
     532        def eb(failure):
     533            failure.trap(RosterPushIgnored)
     534            raise error.StanzaError('service-unavailable')
    478535
    479         iq.handled = True
     536        item = RosterItem.fromElement(iq.query.item)
    480537
    481         itemElement = iq.query.item
     538        if item.remove:
     539            d = defer.maybeDeferred(self.onRosterRemove, item.entity)
     540        else:
     541            d = defer.maybeDeferred(self.onRosterSet, item)
    482542
    483         if unicode(itemElement['subscription']) == 'remove':
    484             self.onRosterRemove(jid.internJID(itemElement['jid']))
    485         else:
    486             item = self._parseRosterItem(iq.query.item)
    487             self.onRosterSet(item)
     543        d.addErrback(eb)
     544        return d
    488545
    489546
    490547    def onRosterSet(self, item):
    491548        """
    492549        Called when a roster push for a new or update item was received.
    493550
     551        Raise L{RosterPushIgnored} when not accepting this roster push
     552        (directly or via Deferred). This will result in a
     553        L{'service-unavailable'} error being sent in return.
     554
    494555        @param item: The pushed roster item.
    495556        @type item: L{RosterItem}
    496557        """
     
    500561        """
    501562        Called when a roster push for the removal of an item was received.
    502563
     564        Raise L{RosterPushIgnored} when not accepting this roster push
     565        (directly or via Deferred). This will result in a
     566        L{'service-unavailable'} error being sent in return.
     567
    503568        @param entity: The entity for which the roster item has been removed.
    504569        @type entity: L{jid.JID}
    505570        """
  • wokkel/test/test_im.py

    diff -r a3c50205821b wokkel/test/test_im.py
    a b  
    77
    88from twisted.internet import defer
    99from twisted.trial import unittest
     10from twisted.words.protocols.jabber import error
    1011from twisted.words.protocols.jabber.jid import JID
    1112from twisted.words.protocols.jabber.xmlstream import toResponse
    1213from twisted.words.xish import domish, utility
    1314
    1415from wokkel import im
    1516from wokkel.generic import ErrorStanza, parseXml
    16 from wokkel.test.helpers import XmlStreamStub
     17from wokkel.test.helpers import TestableRequestHandlerMixin, XmlStreamStub
    1718
    1819NS_XML = 'http://www.w3.org/XML/1998/namespace'
    1920NS_ROSTER = 'jabber:iq:roster'
     
    389390
    390391
    391392
    392 class RosterClientProtocolTest(unittest.TestCase):
     393class RosterItemTest(unittest.TestCase):
     394    """
     395    Tests for L{im.RosterItem}.
     396    """
     397
     398    def test_toElement(self):
     399        item = im.RosterItem(JID('user@example.org'))
     400        element = item.toElement()
     401        self.assertEquals('item', element.name)
     402        self.assertEquals(NS_ROSTER, element.uri)
     403        self.assertFalse(element.hasAttribute('subscription'))
     404        self.assertFalse(element.hasAttribute('ask'))
     405        self.assertFalse(element.hasAttribute('name'))
     406        self.assertFalse(element.hasAttribute('approved'))
     407        self.assertEquals(0, len(list(element.elements())))
     408
     409
     410    def test_toElementMinimal(self):
     411        item = im.RosterItem(JID('user@example.org'))
     412        element = item.toElement()
     413        self.assertEquals(u'user@example.org', element.getAttribute('jid'))
     414
     415
     416    def test_toElementSubscriptionNone(self):
     417        item = im.RosterItem(JID('user@example.org'),
     418                                 subscriptionTo=False,
     419                                 subscriptionFrom=False)
     420        element = item.toElement()
     421        self.assertIdentical(None, element.getAttribute('subscription'))
     422
     423
     424    def test_toElementSubscriptionTo(self):
     425        item = im.RosterItem(JID('user@example.org'),
     426                                 subscriptionTo=True,
     427                                 subscriptionFrom=False)
     428        element = item.toElement()
     429        self.assertEquals('to', element.getAttribute('subscription'))
     430
     431
     432    def test_toElementSubscriptionFrom(self):
     433        item = im.RosterItem(JID('user@example.org'),
     434                                 subscriptionTo=False,
     435                                 subscriptionFrom=True)
     436        element = item.toElement()
     437        self.assertEquals('from', element.getAttribute('subscription'))
     438
     439
     440    def test_toElementSubscriptionBoth(self):
     441        item = im.RosterItem(JID('user@example.org'),
     442                                 subscriptionTo=True,
     443                                 subscriptionFrom=True)
     444        element = item.toElement()
     445        self.assertEquals('both', element.getAttribute('subscription'))
     446
     447
     448    def test_toElementSubscriptionRemove(self):
     449        item = im.RosterItem(JID('user@example.org'))
     450        item.remove = True
     451        element = item.toElement()
     452        self.assertEquals('remove', element.getAttribute('subscription'))
     453
     454
     455    def test_toElementAsk(self):
     456        item = im.RosterItem(JID('user@example.org'))
     457        item.pendingOut = True
     458        element = item.toElement()
     459        self.assertEquals('subscribe', element.getAttribute('ask'))
     460
     461
     462    def test_toElementName(self):
     463        item = im.RosterItem(JID('user@example.org'),
     464                                 name='Joe User')
     465        element = item.toElement()
     466        self.assertEquals(u'Joe User', element.getAttribute('name'))
     467
     468
     469    def test_toElementGroups(self):
     470        groups = set(['Friends', 'Jabber'])
     471        item = im.RosterItem(JID('user@example.org'),
     472                                 groups=groups)
     473
     474        element = item.toElement()
     475        foundGroups = set()
     476        for child in element.elements():
     477            if child.uri == NS_ROSTER and child.name == 'group':
     478                foundGroups.add(unicode(child))
     479
     480        self.assertEqual(groups, foundGroups)
     481
     482
     483    def test_toElementApproved(self):
     484        """
     485        A pre-approved subscription for a roster item has an 'approved' flag.
     486        """
     487        item = im.RosterItem(JID('user@example.org'))
     488        item.approved = True
     489        element = item.toElement()
     490        self.assertEquals(u'true', element.getAttribute('approved'))
     491
     492
     493    def test_fromElementMinimal(self):
     494        """
     495        A minimal roster item has a reference to the JID of the contact.
     496        """
     497
     498        xml = """
     499            <item xmlns="jabber:iq:roster"
     500                  jid="test@example.org"/>
     501        """
     502
     503        item = im.RosterItem.fromElement(parseXml(xml))
     504        self.assertEqual(JID(u"test@example.org"), item.entity)
     505        self.assertIdentical(None, item.name)
     506        self.assertFalse(item.subscriptionTo)
     507        self.assertFalse(item.subscriptionFrom)
     508        self.assertFalse(item.pendingOut)
     509        self.assertFalse(item.approved)
     510        self.assertEquals(set(), item.groups)
     511
     512
     513    def test_fromElementName(self):
     514        """
     515        A roster item may have an optional name.
     516        """
     517
     518        xml = """
     519            <item xmlns="jabber:iq:roster"
     520                  jid="test@example.org"
     521                  name="Test User"/>
     522        """
     523
     524        item = im.RosterItem.fromElement(parseXml(xml))
     525        self.assertEqual(u"Test User", item.name)
     526
     527
     528    def test_fromElementGroups(self):
     529        """
     530        A roster item may have one or more groups.
     531        """
     532
     533        xml = """
     534            <item xmlns="jabber:iq:roster"
     535                  jid="test@example.org">
     536              <group>Friends</group>
     537              <group>Twisted</group>
     538            </item>
     539        """
     540
     541        item = im.RosterItem.fromElement(parseXml(xml))
     542        self.assertIn(u"Twisted", item.groups)
     543        self.assertIn(u"Friends", item.groups)
     544
     545
     546    def test_fromElementSubscriptionNone(self):
     547        """
     548        Subscription 'none' sets both attributes to False.
     549        """
     550
     551        xml = """
     552            <item xmlns="jabber:iq:roster"
     553                  jid="test@example.org"
     554                  subscription="none"/>
     555        """
     556
     557        item = im.RosterItem.fromElement(parseXml(xml))
     558        self.assertFalse(item.remove)
     559        self.assertFalse(item.subscriptionTo)
     560        self.assertFalse(item.subscriptionFrom)
     561
     562
     563    def test_fromElementSubscriptionTo(self):
     564        """
     565        Subscription 'to' sets the corresponding attribute to True.
     566        """
     567
     568        xml = """
     569            <item xmlns="jabber:iq:roster"
     570                  jid="test@example.org"
     571                  subscription="to"/>
     572        """
     573
     574        item = im.RosterItem.fromElement(parseXml(xml))
     575        self.assertFalse(item.remove)
     576        self.assertTrue(item.subscriptionTo)
     577        self.assertFalse(item.subscriptionFrom)
     578
     579
     580    def test_fromElementSubscriptionFrom(self):
     581        """
     582        Subscription 'from' sets the corresponding attribute to True.
     583        """
     584
     585        xml = """
     586            <item xmlns="jabber:iq:roster"
     587                  jid="test@example.org"
     588                  subscription="from"/>
     589        """
     590
     591        item = im.RosterItem.fromElement(parseXml(xml))
     592        self.assertFalse(item.remove)
     593        self.assertFalse(item.subscriptionTo)
     594        self.assertTrue(item.subscriptionFrom)
     595
     596
     597    def test_fromElementSubscriptionBoth(self):
     598        """
     599        Subscription 'both' sets both attributes to True.
     600        """
     601
     602        xml = """
     603            <item xmlns="jabber:iq:roster"
     604                  jid="test@example.org"
     605                  subscription="both"/>
     606        """
     607
     608        item = im.RosterItem.fromElement(parseXml(xml))
     609        self.assertFalse(item.remove)
     610        self.assertTrue(item.subscriptionTo)
     611        self.assertTrue(item.subscriptionFrom)
     612
     613
     614    def test_fromElementSubscriptionRemove(self):
     615        """
     616        Subscription 'remove' sets the remove attribute.
     617        """
     618
     619        xml = """
     620            <item xmlns="jabber:iq:roster"
     621                  jid="test@example.org"
     622                  subscription="remove"/>
     623        """
     624
     625        item = im.RosterItem.fromElement(parseXml(xml))
     626        self.assertTrue(item.remove)
     627
     628
     629    def test_fromElementPendingOut(self):
     630        """
     631        The ask attribute, if set to 'subscription', means pending out.
     632        """
     633
     634        xml = """
     635            <item xmlns="jabber:iq:roster"
     636                  jid="test@example.org"
     637                  ask="subscribe"/>
     638        """
     639
     640        item = im.RosterItem.fromElement(parseXml(xml))
     641        self.assertTrue(item.pendingOut)
     642
     643
     644    def test_fromElementApprovedTrue(self):
     645        """
     646        The approved attribute (true) signals a pre-approved subscription.
     647        """
     648
     649        xml = """
     650            <item xmlns="jabber:iq:roster"
     651                  jid="test@example.org"
     652                  approved="true"/>
     653        """
     654
     655        item = im.RosterItem.fromElement(parseXml(xml))
     656        self.assertTrue(item.approved)
     657
     658
     659    def test_fromElementApproved1(self):
     660        """
     661        The approved attribute (1) signals a pre-approved subscription.
     662        """
     663
     664        xml = """
     665            <item xmlns="jabber:iq:roster"
     666                  jid="test@example.org"
     667                  approved="1"/>
     668        """
     669
     670        item = im.RosterItem.fromElement(parseXml(xml))
     671        self.assertTrue(item.approved)
     672
     673
     674
     675class RosterClientProtocolTest(unittest.TestCase, TestableRequestHandlerMixin):
    393676    """
    394677    Tests for L{im.RosterClientProtocol}.
    395678    """
    396679
    397680    def setUp(self):
    398681        self.stub = XmlStreamStub()
    399         self.protocol = im.RosterClientProtocol()
    400         self.protocol.xmlstream = self.stub.xmlstream
    401         self.protocol.connectionInitialized()
     682        self.service = im.RosterClientProtocol()
     683        self.service.makeConnection(self.stub.xmlstream)
     684        self.service.connectionInitialized()
    402685
    403686
    404687    def test_removeItem(self):
    405688        """
    406689        Removing a roster item is setting an item with subscription C{remove}.
    407690        """
    408         d = self.protocol.removeItem(JID('test@example.org'))
     691        d = self.service.removeItem(JID('test@example.org'))
    409692
    410693        # Inspect outgoing iq request
    411694
     
    419702        self.assertEquals(1, len(children))
    420703        child = children[0]
    421704        self.assertEquals('test@example.org', child['jid'])
    422         self.assertEquals('remove', child['subscription'])
     705        self.assertEquals('remove', child.getAttribute('subscription'))
    423706
    424707        # Fake successful response
    425708
    426709        response = toResponse(iq, 'result')
    427710        self.stub.send(response)
    428711        return d
     712
     713
     714    def test_getRoster(self):
     715        def cb(roster):
     716            self.assertIn(JID('user@example.org'), roster)
     717
     718
     719        d = self.service.getRoster()
     720        d.addCallback(cb)
     721
     722        # Inspect outgoing iq request
     723
     724        iq = self.stub.output[-1]
     725        self.assertEquals('get', iq.getAttribute('type'))
     726        self.assertNotIdentical(None, iq.query)
     727        self.assertEquals(NS_ROSTER, iq.query.uri)
     728
     729        # Fake successful response
     730        response = toResponse(iq, 'result')
     731        query = response.addElement((NS_ROSTER, 'query'))
     732        item = query.addElement('item')
     733        item['jid'] = 'user@example.org'
     734
     735        self.stub.send(response)
     736        return d
     737
     738
     739    def test_onRosterSet(self):
     740        """
     741        A roster push causes onRosterSet to be called with the parsed item.
     742        """
     743        xml = """
     744          <iq type='set'>
     745            <query xmlns='jabber:iq:roster'>
     746              <item jid='user@example.org'/>
     747            </query>
     748          </iq>
     749        """
     750
     751        items = []
     752
     753        def onRosterSet(item):
     754            items.append(item)
     755
     756        def cb(result):
     757            self.assertEquals(1, len(items))
     758            self.assertEquals(JID('user@example.org'), items[0].entity)
     759
     760        self.service.onRosterSet = onRosterSet
     761
     762        d = self.handleRequest(xml)
     763        d.addCallback(cb)
     764        return d
     765
     766
     767    def test_onRosterSetUntrusted(self):
     768        """
     769        Roster pushes from untrusted sources will be not be handled.
     770        """
     771        xml = """
     772          <iq type='set' from='bad@example.org'>
     773            <query xmlns='jabber:iq:roster'>
     774              <item jid='user@example.org'/>
     775            </query>
     776          </iq>
     777        """
     778
     779        def onRosterSet(item):
     780            raise im.RosterPushIgnored()
     781
     782        def cb(result):
     783            self.assertEquals('service-unavailable', result.condition)
     784
     785        self.service.onRosterSet = onRosterSet
     786
     787        d = self.handleRequest(xml)
     788        self.assertFailure(d, error.StanzaError)
     789        d.addCallback(cb)
     790        return d
     791
     792
     793    def test_onRosterRemove(self):
     794        """
     795        A roster push causes onRosterSet to be called with the parsed item.
     796        """
     797        xml = """
     798          <iq type='set'>
     799            <query xmlns='jabber:iq:roster'>
     800              <item jid='user@example.org' subscription='remove'/>
     801            </query>
     802          </iq>
     803        """
     804
     805        entities = []
     806
     807        def onRosterRemove(entity):
     808            entities.append(entity)
     809
     810        def cb(result):
     811            self.assertEquals([JID('user@example.org')], entities)
     812
     813        self.service.onRosterRemove = onRosterRemove
     814
     815        d = self.handleRequest(xml)
     816        d.addCallback(cb)
     817        return d
     818
Note: See TracBrowser for help on using the repository browser.