diff options
-rw-r--r-- | docs/api/plugins/xep_0100.rst | 9 | ||||
-rw-r--r-- | slixmpp/plugins/__init__.py | 1 | ||||
-rw-r--r-- | slixmpp/plugins/xep_0100/__init__.py | 6 | ||||
-rw-r--r-- | slixmpp/plugins/xep_0100/gateway.py | 257 | ||||
-rw-r--r-- | tests/test_stream_xep_0100.py | 416 |
5 files changed, 689 insertions, 0 deletions
diff --git a/docs/api/plugins/xep_0100.rst b/docs/api/plugins/xep_0100.rst new file mode 100644 index 00000000..15c99ed1 --- /dev/null +++ b/docs/api/plugins/xep_0100.rst @@ -0,0 +1,9 @@ + +XEP-0106: Gateway interaction +============================= + +.. module:: slixmpp.plugins.xep_0100 + +.. autoclass:: XEP_0100 + :members: + :exclude-members: session_bind, plugin_init, plugin_end diff --git a/slixmpp/plugins/__init__.py b/slixmpp/plugins/__init__.py index 70ec55d2..d087f92b 100644 --- a/slixmpp/plugins/__init__.py +++ b/slixmpp/plugins/__init__.py @@ -42,6 +42,7 @@ __all__ = [ 'xep_0092', # Software Version # 'xep_0095', # Legacy Stream Initiation. Don’t automatically load # 'xep_0096', # Legacy SI File Transfer. Don’t automatically load + 'xep_0100', # Gateway interaction 'xep_0106', # JID Escaping 'xep_0107', # User Mood 'xep_0108', # User Activity diff --git a/slixmpp/plugins/xep_0100/__init__.py b/slixmpp/plugins/xep_0100/__init__.py new file mode 100644 index 00000000..101b8db0 --- /dev/null +++ b/slixmpp/plugins/xep_0100/__init__.py @@ -0,0 +1,6 @@ +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0100.gateway import XEP_0100, LegacyError + + +register_plugin(XEP_0100) diff --git a/slixmpp/plugins/xep_0100/gateway.py b/slixmpp/plugins/xep_0100/gateway.py new file mode 100644 index 00000000..8ff102bb --- /dev/null +++ b/slixmpp/plugins/xep_0100/gateway.py @@ -0,0 +1,257 @@ +import asyncio +import logging +from functools import partial +import typing + +from slixmpp import Message, Iq, Presence, JID +from slixmpp.xmlstream.handler import Callback +from slixmpp.xmlstream.matcher import StanzaPath +from slixmpp.plugins import BasePlugin + + +class XEP_0100(BasePlugin): + + """ + XEP-0100: Gateway interaction + + Does not cover the deprecated Agent Information and 'jabber:iq:gateway' protocols + + Events registered by this plugin: + + - legacy_login: Jabber user got online or just registered + - legacy_logout: Jabber user got offline or just unregistered + - legacy_presence_unavailable: Jabber user sent an unavailable presence to a legacy contact + - gateway_message: Jabber user sent a direct message to the gateway component + - legacy_message: Jabber user sent a message to the legacy network + + + Plugin Parameters: + + - `component_name`: (str) Name of the entity + - `type`: (str) Type of the gateway identity. Should be the name of the legacy service + - `needs_registration`: (bool) If set to True, messages received from unregistered users will + not be transmitted to the legacy service + + API: + + - legacy_contact_add(jid, node, ifrom: JID, args: JID): Add contact on the legacy service. + Should raise LegacyError if anything goes wrong in the process. + `ifrom` is the gateway user's JID and `args` is the legacy contact's JID. + - legacy_contact_remove(jid, node, ifrom: JID, args: JID): Remove a contact. + + """ + + name = "xep_0100" + description = "XEP-0100: Gateway interaction" + dependencies = { + "xep_0030", # Service discovery + "xep_0077", # In band registration + } + + default_config = { + "component_name": "SliXMPP gateway", + "type": "xmpp", + "needs_registration": True, + } + + def plugin_init(self): + if not self.xmpp.is_component: + log.error("Only components can be gateways, aborting plugin load") + return + + self.xmpp["xep_0030"].add_identity( + name=self.component_name, category="gateway", itype=self.type + ) + + self.api.register(self._legacy_contact_remove, "legacy_contact_remove") + self.api.register(self._legacy_contact_add, "legacy_contact_add") + + # Without that BaseXMPP sends unsub/unavailable on sub requests and we don't want that + self.xmpp.client_roster.auto_authorize = True + self.xmpp.client_roster.auto_subscribe = False + + self.xmpp.add_event_handler("user_register", self.on_user_register) + self.xmpp.add_event_handler("user_unregister", self.on_user_unregister) + self.xmpp.add_event_handler("presence_available", self.on_presence_available) + self.xmpp.add_event_handler( + "presence_unavailable", self.on_presence_unavailable + ) + self.xmpp.add_event_handler("presence_subscribe", self.on_presence_subscribe) + self.xmpp.add_event_handler( + "presence_unsubscribe", self.on_presence_unsubscribe + ) + self.xmpp.add_event_handler("message", self.on_message) + + def plugin_end(self): + if not self.xmpp.is_component: + return + + self.xmpp.del_event_handler("user_register", self.on_user_register) + self.xmpp.del_event_handler("user_unregister", self.on_user_unregister) + self.xmpp.del_event_handler("presence_available", self.on_presence_available) + self.xmpp.del_event_handler( + "presence_unavailable", self.on_presence_unavailable + ) + self.xmpp.del_event_handler("presence_subscribe", self.on_presence_subscribe) + self.xmpp.del_event_handler("message", self.on_message) + self.xmpp.del_event_handler( + "presence_unsubscribe", self.on_presence_unsubscribe + ) + + async def get_user(self, stanza): + return await self.xmpp["xep_0077"].api["user_get"](None, None, None, stanza) + + def send_presence(self, pto, ptype=None, pstatus=None, pfrom=None): + self.xmpp.send_presence( + pfrom=self.xmpp.boundjid.bare, + ptype=ptype, + pto=pto, + pstatus=pstatus, + ) + + async def on_user_register(self, iq: Iq): + user_jid = iq["from"] + user = await self.get_user(iq) + if user is None: # This should not happen + log.warning(f"{user_jid} has registered but cannot find them in user store") + else: + log.debug(f"Sending subscription request to {user_jid}") + self.xmpp.client_roster.subscribe(user_jid) + + def on_user_unregister(self, iq: Iq): + user_jid = iq["from"] + log.debug(f"Sending subscription request to {user_jid}") + self.xmpp.event("legacy_logout", iq) + self.xmpp.client_roster.unsubscribe(iq["from"]) + self.xmpp.client_roster.remove(iq["from"]) + log.debug(f"roster: {self.xmpp.client_roster}") + + async def on_presence_available(self, presence: Presence): + user_jid = presence["from"] + user = await self.get_user(presence) + if user is None: + log.warning( + f"{user_jid} has gotten online but cannot find them in user store" + ) + else: + self.xmpp.event("legacy_login", presence) + log.debug(f"roster: {self.xmpp.client_roster}") + self.send_presence(pto=user_jid.bare, ptype="available") + + async def on_presence_unavailable(self, presence: Presence): + user_jid = presence["from"] + user = await self.get_user(presence) + if user is None: # This should not happen + log.warning( + f"{user_jid} has gotten offline but but cannot find them in user store" + ) + return + + if presence["to"] == self.xmpp.boundjid.bare: + self.xmpp.event("legacy_logout", presence) + self.send_presence(pto=user_jid, ptype="unavailable") + else: + self.xmpp.event("legacy_presence_unavailable", presence) + + async def _legacy_contact_add(self, jid, node, ifrom, contact_jid: JID): + pass + + async def on_presence_subscribe(self, presence: Presence): + user_jid = presence["from"] + user = await self.get_user(presence) + if user is None and self.needs_registration: + return + + if presence["to"] == self.xmpp.boundjid.bare: + return + + try: + await self.api["legacy_contact_add"]( + ifrom=user_jid, + args=presence["to"], + ) + except LegacyError: + self.xmpp.send_presence( + pfrom=presence["to"], + ptype="unsubscribed", + pto=user_jid, + ) + return + self.xmpp.send_presence( + pfrom=presence["to"], + ptype="subscribed", + pto=user_jid, + ) + self.xmpp.send_presence( + pfrom=presence["to"], + pto=user_jid, + ) + self.xmpp.send_presence( + pfrom=presence["to"], + ptype="subscribe", + pto=user_jid, + ) # TODO: handle resulting subscribed presences + + async def on_presence_unsubscribe(self, presence: Presence): + if presence["to"] == self.xmpp.boundjid.bare: + # should we trigger unregistering here? + return + + user_jid = presence["from"] + user = await self.get_user(presence) + if user is None: + log.debug("Received remove subscription from unregistered user") + if self.needs_registration: + return + + await self.api["legacy_contact_remove"](ifrom=user_jid, args=presence["to"]) + + for ptype in "unsubscribe", "unsubscribed", "unavailable": + self.xmpp.send_presence( + pfrom=presence["to"], + ptype=ptype, + pto=user_jid, + ) + + async def _legacy_contact_remove(self, jid, node, ifrom, contact_jid: JID): + pass + + async def on_message(self, msg: Message): + if msg["type"] == "groupchat": + return # groupchat messages are out of scope of XEP-0100 + + if msg["to"] == self.xmpp.boundjid.bare: + # It may be useful to exchange direct messages with the component + self.xmpp.event("gateway_message", msg) + return + + if self.needs_registration and await self.get_user(msg) is None: + return + + self.xmpp.event("legacy_message", msg) + + def transform_legacy_message( + self, + jabber_user_jid: typing.Union[JID, str], + legacy_contact_id: str, + body: str, + mtype: typing.Optional[str] = None, + ): + """ + Transform a legacy message to an XMPP message + """ + # Should escaping legacy IDs to valid JID local parts be handled here? + # Maybe by internal API stuff? + self.xmpp.send_message( + mfrom=JID(f"{legacy_contact_id}@{self.xmpp.boundjid.bare}"), + mto=JID(jabber_user_jid).bare, + mbody=body, + mtype=mtype, + ) + + +class LegacyError(Exception): + pass + + +log = logging.getLogger(__name__) diff --git a/tests/test_stream_xep_0100.py b/tests/test_stream_xep_0100.py new file mode 100644 index 00000000..24e35f9e --- /dev/null +++ b/tests/test_stream_xep_0100.py @@ -0,0 +1,416 @@ +import unittest +import logging + +from slixmpp import JID +from slixmpp.test import SlixTest + +from slixmpp.plugins import xep_0100 +from slixmpp.plugins.xep_0100 import LegacyError + + +class TestStreamGateway(SlixTest): + def setUp(self): + self.stream_start( + mode="component", + plugins=["xep_0077", "xep_0100"], + jid="aim.shakespeare.lit", + server="shakespeare.lit", + plugin_config={ + "xep_0100": {"component_name": "AIM Gateway", "type": "aim"} + }, + ) + + def next_sent(self): + self.wait_for_send_queue() + sent = self.xmpp.socket.next_sent(timeout=0.5) + if sent is None: + return None + xml = self.parse_xml(sent) + self.fix_namespaces(xml, "jabber:component:accept") + sent = self.xmpp._build_stanza(xml, "jabber:component:accept") + return sent + + def testDisco(self): + # https://xmpp.org/extensions/xep-0100.html#example-3 + self.recv( + """ + <iq type='get' + from='romeo@montague.lit/orchard' + to='aim.shakespeare.lit' + id='disco1'> + <query xmlns='http://jabber.org/protocol/disco#info'/> + </iq> + """ + ) + self.send( + """ + <iq type="result" + from="aim.shakespeare.lit" + to="romeo@montague.lit/orchard" + id="disco1"> + <query xmlns="http://jabber.org/protocol/disco#info"> + <identity category="gateway" type="aim" name="AIM Gateway" /> + <feature var="jabber:iq:register" /> + <feature var="jabber:x:data" /> + <feature var="jabber:iq:oob" /> + <feature var="jabber:x:oob" /> + </query> + </iq> + """ + ) + + def testRegister(self): + event_result = {} + + def legacy_login(iq): + event_result["user"] = iq["from"] + + self.xmpp.add_event_handler("legacy_login", legacy_login) + + # Jabber User sends IQ-set qualified by the 'jabber:iq:register' namespace to Gateway, + # containing information required to register. + # https://xmpp.org/extensions/xep-0100.html#example-7 + self.recv( + """ + <iq type='set' + from='romeo@montague.lit/orchard' + to='aim.shakespeare.lit' + id='reg2'> + <query xmlns='jabber:iq:register'> + <username>RomeoMyRomeo</username> + <password>ILoveJuliet</password> + </query> + </iq> + """ + ) + # Gateway verifies that registration information provided by Jabber User is valid + # (using whatever means appropriate for the Legacy Service) and informs Jabber User of success [A1]. + # https://xmpp.org/extensions/xep-0100.html#example-8 + self.send( + """ + <iq type='result' + from='aim.shakespeare.lit' + to='romeo@montague.lit/orchard' + id='reg2'/> + """ + ) + # Gateway sends subscription request to Jabber User (i.e., by sending a presence stanza + # of type "subscribe" to Jabber User's bare JID). + # https://xmpp.org/extensions/xep-0100.html#example-11 + sent = self.next_sent() + self.check( + sent, "/presence@type=subscribe@from=aim.shakespeare.lit", "stanzapath" + ) + self.assertTrue( + sent["to"] == "romeo@montague.lit" + ) # cannot use stanzapath because of @ + # Jabber User's client SHOULD approve the subscription request (i.e., by sending a presence stanza + # of type "subscribed" to Gateway). + self.recv( + """ + <presence type='subscribed' + from='romeo@montague.lit' + to='aim.shakespeare.lit'/> + """ + ) + # Jabber User sends subscription request to Gateway (i.e., by sending a presence stanza + # of type "subscribe" to Gateway). + self.recv( + """ + <presence type='subscribe' + from='romeo@montague.lit' + to='aim.shakespeare.lit'/> + """ + ) + # Gateway sends approves subscription request (i.e., by sending a presence stanza of type + # "subscribed" to Jabber User's bare JID). + sent = self.next_sent() + self.check( + sent, "/presence@type=subscribed@from=aim.shakespeare.lit", "stanzapath" + ) + self.assertTrue( + sent["to"] == "romeo@montague.lit" + ) # cannot use stanzapath because of @ + self.assertTrue( + self.xmpp.client_roster["romeo@montague.lit"]["subscription"] == "both" + ) + self.recv( + """ + <presence from='romeo@montague.lit/orchard' + to='aim.shakespeare.lit'/> + """ + ) + self.assertTrue(event_result["user"] == "romeo@montague.lit/orchard") + + def testBadCredentials(self): + def raise_v(*a, **kwa): + raise ValueError("Not good") + + self.xmpp["xep_0077"].api.register(raise_v, "user_validate") + self.recv( + """ + <iq type='set' + from='romeo@montague.lit/orchard' + to='aim.shakespeare.lit' + id='reg2'> + <query xmlns='jabber:iq:register'> + <username>RomeoMyRomeo</username> + <password>ILoveJuliet</password> + </query> + </iq> + """ + ) + # xmlns="jabber:client" in error substanza, bug in XEP-0077 plugin or OK? + self.send( + """ + <iq type='error' + from='aim.shakespeare.lit' + to='romeo@montague.lit/orchard' + id='reg2'> + <query xmlns='jabber:iq:register'> + <username>RomeoMyRomeo</username> + <password>ILoveJuliet</password> + </query> + <error code='406' type='modify' xmlns="jabber:client"> + <not-acceptable + xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/> + <text xmlns="urn:ietf:params:xml:ns:xmpp-stanzas">Not good</text> + </error> + </iq> + """, + use_values=False, + ) + + def testLogin(self): + event_result = {} + + def legacy_login(presence): + event_result["user"] = presence["from"] + + self.xmpp.add_event_handler("legacy_login", legacy_login) + + self.xmpp["xep_0077"].api["user_validate"]( + None, + None, + JID("romeo@montague.lit"), + {"username": "RomeoMyRomeo", "password": "ILoveJuliet"}, + ) + + # Jabber User sends available presence broadcast to Server or sends + # directed presence to Gateway or a Legacy User. + # https://xmpp.org/extensions/xep-0100.html#example-26 + self.recv( + """ + <presence from='romeo@montague.lit/orchard' + to='juliet@aim.shakespeare.lit'/> + <presence from='romeo@montague.lit/orchard' + to='aim.shakespeare.lit'/> + """ + ) + # Gateway sends presence stanza to Jabber User expressing availability. + # https://xmpp.org/extensions/xep-0100.html#example-27 + self.send( + """ + <presence from='aim.shakespeare.lit' + to='romeo@montague.lit'> + <priority>0</priority> + </presence> + """ + ) + self.assertTrue(event_result["user"] == "romeo@montague.lit/orchard") + + def testLogout(self): + self.add_user() + event_result = {} + + def legacy_logout(presence): + event_result["user"] = presence["from"] + + self.xmpp.add_event_handler("legacy_logout", legacy_logout) + # Jabber User sends available presence broadcast to Server or sends + # directed presence to Gateway or a Legacy User. + # https://xmpp.org/extensions/xep-0100.html#example-32 + self.recv( + """ + <presence type='unavailable' + from='romeo@montague.lit/orchard' + to='aim.shakespeare.lit'/> + """ + ) + # Gateway sends presence stanza of type "unavailable" to Jabber User. + # https://xmpp.org/extensions/xep-0100.html#example-33 + self.send( + """ + <presence type='unavailable' + from='aim.shakespeare.lit' + to='romeo@montague.lit/orchard'> + <priority>0</priority> + </presence> + """ + ) + self.assertTrue(event_result["user"] == "romeo@montague.lit/orchard") + + def testAddContact(self): + self.add_user() + # Had to lowercase capuletnurse everywhere + # Jabber User sends presence stanza of type "subscribe" to Legacy User. + self.recv( + """ + <presence type='subscribe' + from='romeo@montague.lit' + to='capuletnurse@aim.shakespeare.lit'/> + """ + ) + # If Legacy User approves subscription request, Gateway sends presence stanza of + # type "subscribed" to Jabber User on behalf of Legacy User. [A1] + self.send( + """ + <presence type='subscribed' + from='capuletnurse@aim.shakespeare.lit' + to='romeo@montague.lit'/> + """ + ) + # Had to remove the resource here + self.send( + """ + <presence from='capuletnurse@aim.shakespeare.lit' + to='romeo@montague.lit'/> + """ + ) + self.send( + """ + <presence type='subscribe' + from='capuletnurse@aim.shakespeare.lit' + to='romeo@montague.lit'/> + """ + ) + self.recv( + """ + <presence type='subscribed' + from='romeo@montague.lit' + to='capuletnurse@aim.shakespeare.lit'/> + """ + ) + + def testAddContactFail(self): + self.add_user() + res = {} + async def legacy_contact_add(jid, node, ifrom, contact_jid): + res.update(**locals()) + raise LegacyError + self.xmpp["xep_0100"].api.register( + legacy_contact_add, "legacy_contact_add" + ) + self.recv( + """ + <presence type='subscribe' + from='romeo@montague.lit' + to='juliet@aim.shakespeare.lit'/> + """ + ) + self.send( + """ + <presence type='unsubscribed' + from='juliet@aim.shakespeare.lit' + to='romeo@montague.lit'/> + """ + ) + self.assertTrue(res["ifrom"] == "romeo@montague.lit") + self.assertTrue(res["contact_jid"] == "juliet@aim.shakespeare.lit") + + + def testRemoveContact(self): + self.add_user() + result = {} + # Jabber User sends IQ-set qualified by the 'jabber:iq:roster' namespace, containing subscription + # attribute with value of "remove". + async def legacy_contact_remove(jid, node, ifrom, contact_jid): + result.update(**locals()) + + self.xmpp["xep_0100"].api.register( + legacy_contact_remove, "legacy_contact_remove" + ) + + # Jabber User sends IQ-set qualified by the 'jabber:iq:roster' namespace, containing subscription + # attribute with value of "remove". + self.recv( # server sends this + """ + <presence type='unsubscribe' + to='capuletnurse@aim.shakespeare.lit' + from='romeo@montague.lit'/> + """ + ) + for ptype in "unsubscribe", "unsubscribed", "unavailable": + self.send( # server sends this + f""" + <presence type='{ptype}' + from='capuletnurse@aim.shakespeare.lit' + to='romeo@montague.lit'/> + """ + ) + + self.assertTrue(result["ifrom"] == "romeo@montague.lit") + self.assertTrue( + result["contact_jid"] == JID("CapuletNurse@aim.shakespeare.lit") + ) + + def testSendMessage(self): + self.xmpp["xep_0100"].transform_legacy_message( + jabber_user_jid="romeo@montague.lit", + legacy_contact_id="juliet", + body="Art thou not Romeo, and a Montague?", + ) + self.send( + """ + <message from='juliet@aim.shakespeare.lit' + to='romeo@montague.lit'> + <body>Art thou not Romeo, and a Montague?</body> + </message> + """ + ) + + def testLegacyMessage(self): + self.add_user() + result = {} + + def legacy_message(msg): + result["msg"] = msg + + self.xmpp.add_event_handler("legacy_message", legacy_message) + self.recv( + """ + <message to='juliet@aim.shakespeare.lit' + from='romeo@montague.lit'> + <body>Something shakespearian</body> + </message> + """ + ) + self.wait_for_send_queue() + self.assertTrue(result["msg"]["from"] == "romeo@montague.lit") + self.assertTrue(result["msg"]["to"] == "juliet@aim.shakespeare.lit") + + def testPluginEnd(self): + exc = False + try: + self.xmpp.plugin.disable("xep_0100") + except Exception: + exc = True + self.assertFalse(exc) + + def add_user(self): + self.xmpp.loop.run_until_complete( + self.xmpp["xep_0077"].api["user_validate"]( + None, + None, + JID("romeo@montague.lit"), + {"username": "RomeoMyRomeo", "password": "ILoveJuliet"}, + ) + ) + + # TODO: edit reg + # TODO: unregister + # TODO: login fails + + +logging.basicConfig(level=logging.DEBUG) +suite = unittest.TestLoader().loadTestsFromTestCase(TestStreamGateway) |