diff options
40 files changed, 1897 insertions, 545 deletions
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 839de025..0a4013b6 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -9,7 +9,8 @@ test: image: ubuntu:latest script: - apt update - - apt install -y python3 cython3 gpg + - apt install -y python3 python3-pip cython3 gpg + - pip3 install emoji - ./run_tests.py trigger_poezio: diff --git a/docs/getting_started/muc.rst b/docs/getting_started/muc.rst index 653175eb..0b5cd1f8 100644 --- a/docs/getting_started/muc.rst +++ b/docs/getting_started/muc.rst @@ -60,12 +60,11 @@ has been established: .. code-block:: python - def start(self, event): - self.get_roster() + async def start(self, event): + await self.get_roster() self.send_presence() self.plugin['xep_0045'].join_muc(self.room, - self.nick, - wait=True) + self.nick) Note that as in :ref:`echobot`, we need to include send an initial presence and request the roster. Next, we want to join the group chat, so we call the diff --git a/examples/muc.py b/examples/muc.py index e3433b8f..62e8e898 100755 --- a/examples/muc.py +++ b/examples/muc.py @@ -71,7 +71,7 @@ class MUCBot(slixmpp.ClientXMPP): self.nick, # If a room password is needed, use: # password=the_room_password, - wait=True) + ) def muc_message(self, msg): """ @@ -30,6 +30,7 @@ CLASSIFIERS = [ 'Programming Language :: Python', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', 'Topic :: Internet :: XMPP', 'Topic :: Software Development :: Libraries :: Python Modules', ] @@ -82,7 +83,7 @@ setup( platforms=['any'], packages=packages, ext_modules=ext_modules, - install_requires=['aiodns>=1.0', 'pyasn1', 'pyasn1_modules', 'aiohttp'], + install_requires=['aiodns>=1.0', 'pyasn1', 'pyasn1_modules', 'aiohttp', 'emoji'], classifiers=CLASSIFIERS, cmdclass={'test': TestCommand} ) diff --git a/slixmpp/plugins/__init__.py b/slixmpp/plugins/__init__.py index c7736adc..91f062a3 100644 --- a/slixmpp/plugins/__init__.py +++ b/slixmpp/plugins/__init__.py @@ -85,7 +85,11 @@ __all__ = [ 'xep_0323', # IoT Systems Sensor Data 'xep_0325', # IoT Systems Control 'xep_0332', # HTTP Over XMPP Transport + 'xep_0369', # MIX-CORE 'xep_0377', # Spam reporting - 'protoxep_reactions', # https://dino.im/xeps/reactions.html - 'protoxep_occupantid', # https://dino.im/xeps/occupant-id.html + 'xep_0403', # MIX-Presence + 'xep_0404', # MIX-Anon + 'xep_0405', # MIX-PAM + 'xep_0421', # Anonymous unique occupant identifiers for MUCs + 'xep_0444', # Message Reactions ] diff --git a/slixmpp/plugins/protoxep_occupantid/__init__.py b/slixmpp/plugins/protoxep_occupantid/__init__.py deleted file mode 100644 index 1bd374b6..00000000 --- a/slixmpp/plugins/protoxep_occupantid/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -""" - Slixmpp: The Slick XMPP Library - Copyright (C) 2019 Mathieu Pasquet - This file is part of Slixmpp. - - See the file LICENSE for copying permission. -""" -from slixmpp.plugins.base import register_plugin -from slixmpp.plugins.protoxep_occupantid.occupantid import XEP_OccupantID -from slixmpp.plugins.protoxep_occupantid.stanza import OccupantID - -register_plugin(XEP_OccupantID) diff --git a/slixmpp/plugins/protoxep_occupantid/occupantid.py b/slixmpp/plugins/protoxep_occupantid/occupantid.py deleted file mode 100644 index 7f4a9d4a..00000000 --- a/slixmpp/plugins/protoxep_occupantid/occupantid.py +++ /dev/null @@ -1,23 +0,0 @@ -""" - Slixmpp: The Slick XMPP Library - Copyright (C) 2019 Mathieu Pasquet - This file is part of Slixmpp. - - See the file LICENSE for copying permission. -""" -from slixmpp.plugins import BasePlugin -from slixmpp.stanza import Message, Presence -from slixmpp.xmlstream import register_stanza_plugin - -from slixmpp.plugins.protoxep_occupantid import stanza - - -class XEP_OccupantID(BasePlugin): - name = 'protoxep_occupantid' - description = 'XEP-XXXX: Anonymous unique occupant identifiers for MUCs' - dependencies = set() - stanza = stanza - - def plugin_init(self): - register_stanza_plugin(Message, stanza.OccupantID) - register_stanza_plugin(Presence, stanza.OccupantID) diff --git a/slixmpp/plugins/protoxep_occupantid/stanza.py b/slixmpp/plugins/protoxep_occupantid/stanza.py deleted file mode 100644 index e5853111..00000000 --- a/slixmpp/plugins/protoxep_occupantid/stanza.py +++ /dev/null @@ -1,16 +0,0 @@ -""" - Slixmpp: The Slick XMPP Library - Copyright (C) 2019 Mathieu Pasquet - This file is part of Slixmpp. - - See the file LICENSE for copying permission. -""" - -from slixmpp.xmlstream import ElementBase - - -class OccupantID(ElementBase): - name = 'occupant-id' - plugin_attrib = 'occupant-id' - namespace = 'urn:xmpp:occupant-id:0' - interfaces = {'id'} diff --git a/slixmpp/plugins/protoxep_reactions/stanza.py b/slixmpp/plugins/protoxep_reactions/stanza.py deleted file mode 100644 index 45414a37..00000000 --- a/slixmpp/plugins/protoxep_reactions/stanza.py +++ /dev/null @@ -1,31 +0,0 @@ -""" - Slixmpp: The Slick XMPP Library - Copyright (C) 2019 Mathieu Pasquet - This file is part of Slixmpp. - - See the file LICENSE for copying permission. -""" - -from slixmpp.xmlstream import ElementBase, register_stanza_plugin - - -class Reactions(ElementBase): - name = 'reactions' - plugin_attrib = 'reactions' - namespace = 'urn:xmpp:reactions:0' - interfaces = {'to'} - - -class Reaction(ElementBase): - name = 'reaction' - namespace = 'urn:xmpp:reactions:0' - interfaces = {'value'} - - def get_value(self) -> str: - return self.xml.text - - def set_value(self, value: str): - self.xml.text = value - - -register_stanza_plugin(Reactions, Reaction, iterable=True) diff --git a/slixmpp/plugins/xep_0045.py b/slixmpp/plugins/xep_0045.py deleted file mode 100644 index dfbb3b58..00000000 --- a/slixmpp/plugins/xep_0045.py +++ /dev/null @@ -1,422 +0,0 @@ -""" - Slixmpp: The Slick XMPP Library - Copyright (C) 2010 Nathanael C. Fritz - This file is part of Slixmpp. - - See the file LICENSE for copying permission. -""" -from __future__ import with_statement - -import logging - -from slixmpp import Presence, Message -from slixmpp.plugins import BasePlugin, register_plugin -from slixmpp.xmlstream import register_stanza_plugin, ElementBase, JID, ET -from slixmpp.xmlstream.handler.callback import Callback -from slixmpp.xmlstream.matcher.xpath import MatchXPath -from slixmpp.xmlstream.matcher.xmlmask import MatchXMLMask -from slixmpp.exceptions import IqError, IqTimeout - - -log = logging.getLogger(__name__) - - -class MUCPresence(ElementBase): - name = 'x' - namespace = 'http://jabber.org/protocol/muc#user' - plugin_attrib = 'muc' - interfaces = {'affiliation', 'role', 'jid', 'nick', 'room'} - affiliations = {'', } - roles = {'', } - - def get_item_attr(self, attr, default): - item = self.xml.find('{http://jabber.org/protocol/muc#user}item') - if item is None: - return default - return item.get(attr) - - def set_item_attr(self, attr, value): - item = self.xml.find('{http://jabber.org/protocol/muc#user}item') - if item is None: - item = ET.Element('{http://jabber.org/protocol/muc#user}item') - self.xml.append(item) - item.attrib[attr] = value - return item - - def del_item_attr(self, attr): - item = self.xml.find('{http://jabber.org/protocol/muc#user}item') - if item is not None and attr in item.attrib: - del item.attrib[attr] - - def get_affiliation(self): - return self.get_item_attr('affiliation', '') - - def set_affiliation(self, value): - self.set_item_attr('affiliation', value) - return self - - def del_affiliation(self): - # TODO: set default affiliation - self.del_item_attr('affiliation') - return self - - def get_jid(self): - return JID(self.get_item_attr('jid', '')) - - def set_jid(self, value): - if not isinstance(value, str): - value = str(value) - self.set_item_attr('jid', value) - return self - - def del_jid(self): - self.del_item_attr('jid') - return self - - def get_role(self): - return self.get_item_attr('role', '') - - def set_role(self, value): - # TODO: check for valid role - self.set_item_attr('role', value) - return self - - def del_role(self): - # TODO: set default role - self.del_item_attr('role') - return self - - def get_nick(self): - return self.parent()['from'].resource - - def get_room(self): - return self.parent()['from'].bare - - def set_nick(self, value): - log.warning("Cannot set nick through mucpresence plugin.") - return self - - def set_room(self, value): - log.warning("Cannot set room through mucpresence plugin.") - return self - - def del_nick(self): - log.warning("Cannot delete nick through mucpresence plugin.") - return self - - def del_room(self): - log.warning("Cannot delete room through mucpresence plugin.") - return self - - -class XEP_0045(BasePlugin): - - """ - Implements XEP-0045 Multi-User Chat - """ - - name = 'xep_0045' - description = 'XEP-0045: Multi-User Chat' - dependencies = {'xep_0030', 'xep_0004'} - - def plugin_init(self): - self.rooms = {} - self.our_nicks = {} - self.xep = '0045' - # load MUC support in presence stanzas - register_stanza_plugin(Presence, MUCPresence) - self.xmpp.register_handler(Callback('MUCPresence', MatchXMLMask("<presence xmlns='%s' />" % self.xmpp.default_ns), self.handle_groupchat_presence)) - self.xmpp.register_handler(Callback('MUCError', MatchXMLMask("<message xmlns='%s' type='error'><error/></message>" % self.xmpp.default_ns), self.handle_groupchat_error_message)) - self.xmpp.register_handler(Callback('MUCMessage', MatchXMLMask("<message xmlns='%s' type='groupchat'><body/></message>" % self.xmpp.default_ns), self.handle_groupchat_message)) - self.xmpp.register_handler(Callback('MUCSubject', MatchXMLMask("<message xmlns='%s' type='groupchat'><subject/></message>" % self.xmpp.default_ns), self.handle_groupchat_subject)) - self.xmpp.register_handler(Callback('MUCConfig', MatchXMLMask("<message xmlns='%s' type='groupchat'><x xmlns='http://jabber.org/protocol/muc#user'><status/></x></message>" % self.xmpp.default_ns), self.handle_config_change)) - self.xmpp.register_handler(Callback('MUCInvite', MatchXPath("{%s}message/{%s}x/{%s}invite" % ( - self.xmpp.default_ns, - 'http://jabber.org/protocol/muc#user', - 'http://jabber.org/protocol/muc#user')), self.handle_groupchat_invite)) - - def plugin_end(self): - self.xmpp.plugin['xep_0030'].del_feature(feature='http://jabber.org/protocol/muc') - - def session_bind(self, jid): - self.xmpp.plugin['xep_0030'].add_feature('http://jabber.org/protocol/muc') - - def handle_groupchat_invite(self, inv): - """ Handle an invite into a muc. - """ - logging.debug("MUC invite to %s from %s: %s", inv['to'], inv["from"], inv) - if inv['from'] not in self.rooms.keys(): - self.xmpp.event("groupchat_invite", inv) - - def handle_config_change(self, msg): - """Handle a MUC configuration change (with status code).""" - self.xmpp.event('groupchat_config_status', msg) - self.xmpp.event('muc::%s::config_status' % msg['from'].bare , msg) - - def handle_groupchat_presence(self, pr): - """ Handle a presence in a muc. - """ - got_offline = False - got_online = False - if pr['muc']['room'] not in self.rooms.keys(): - return - self.xmpp.roster[pr['from']].ignore_updates = True - entry = pr['muc'].get_stanza_values() - entry['show'] = pr['show'] if pr['show'] in pr.showtypes else None - entry['status'] = pr['status'] - entry['alt_nick'] = pr['nick'] - if pr['type'] == 'unavailable': - if entry['nick'] in self.rooms[entry['room']]: - del self.rooms[entry['room']][entry['nick']] - got_offline = True - else: - if entry['nick'] not in self.rooms[entry['room']]: - got_online = True - self.rooms[entry['room']][entry['nick']] = entry - log.debug("MUC presence from %s/%s : %s", entry['room'],entry['nick'], entry) - self.xmpp.event("groupchat_presence", pr) - self.xmpp.event("muc::%s::presence" % entry['room'], pr) - if got_offline: - self.xmpp.event("muc::%s::got_offline" % entry['room'], pr) - if got_online: - self.xmpp.event("muc::%s::got_online" % entry['room'], pr) - - def handle_groupchat_message(self, msg: Message) -> None: - """ Handle a message event in a muc. - """ - self.xmpp.event('groupchat_message', msg) - self.xmpp.event("muc::%s::message" % msg['from'].bare, msg) - - def handle_groupchat_error_message(self, msg): - """ Handle a message error event in a muc. - """ - self.xmpp.event('groupchat_message_error', msg) - self.xmpp.event("muc::%s::message_error" % msg['from'].bare, msg) - - - - def handle_groupchat_subject(self, msg: Message) -> None: - """ Handle a message coming from a muc indicating - a change of subject (or announcing it when joining the room) - """ - # See poezio#3452. A message containing subject _and_ (body or thread) - # is not a subject change. - if msg['body'] or msg['thread']: - return None - self.xmpp.event('groupchat_subject', msg) - - def jid_in_room(self, room, jid): - for nick in self.rooms[room]: - entry = self.rooms[room][nick] - if entry is not None and entry['jid'].full == jid: - return True - return False - - def get_nick(self, room, jid): - for nick in self.rooms[room]: - entry = self.rooms[room][nick] - if entry is not None and entry['jid'].full == jid: - return nick - - def configure_room(self, room, form=None, ifrom=None): - if form is None: - form = self.get_room_config(room, ifrom=ifrom) - iq = self.xmpp.make_iq_set() - iq['to'] = room - if ifrom is not None: - iq['from'] = ifrom - query = ET.Element('{http://jabber.org/protocol/muc#owner}query') - form['type'] = 'submit' - query.append(form) - iq.append(query) - # For now, swallow errors to preserve existing API - try: - result = iq.send() - except IqError: - return False - except IqTimeout: - return False - return True - - def join_muc(self, room, nick, maxhistory="0", password='', wait=False, pstatus=None, pshow=None, pfrom=None): - """ Join the specified room, requesting 'maxhistory' lines of history. - """ - stanza = self.xmpp.make_presence(pto="%s/%s" % (room, nick), pstatus=pstatus, pshow=pshow, pfrom=pfrom) - x = ET.Element('{http://jabber.org/protocol/muc}x') - if password: - passelement = ET.Element('{http://jabber.org/protocol/muc}password') - passelement.text = password - x.append(passelement) - if maxhistory: - history = ET.Element('{http://jabber.org/protocol/muc}history') - if maxhistory == "0": - history.attrib['maxchars'] = maxhistory - else: - history.attrib['maxstanzas'] = maxhistory - x.append(history) - stanza.append(x) - if not wait: - self.xmpp.send(stanza) - else: - #wait for our own room presence back - expect = ET.Element("{%s}presence" % self.xmpp.default_ns, {'from':"%s/%s" % (room, nick)}) - self.xmpp.send(stanza, expect) - self.rooms[room] = {} - self.our_nicks[room] = nick - - def destroy(self, room, reason='', altroom = '', ifrom=None): - iq = self.xmpp.make_iq_set() - if ifrom is not None: - iq['from'] = ifrom - iq['to'] = room - query = ET.Element('{http://jabber.org/protocol/muc#owner}query') - destroy = ET.Element('{http://jabber.org/protocol/muc#owner}destroy') - if altroom: - destroy.attrib['jid'] = altroom - xreason = ET.Element('{http://jabber.org/protocol/muc#owner}reason') - xreason.text = reason - destroy.append(xreason) - query.append(destroy) - iq.append(query) - # For now, swallow errors to preserve existing API - try: - r = iq.send() - except IqError: - return False - except IqTimeout: - return False - return True - - def set_affiliation(self, room, jid=None, nick=None, affiliation='member', ifrom=None): - """ Change room affiliation.""" - if affiliation not in ('outcast', 'member', 'admin', 'owner', 'none'): - raise TypeError - query = ET.Element('{http://jabber.org/protocol/muc#admin}query') - if nick is not None: - item = ET.Element('{http://jabber.org/protocol/muc#admin}item', {'affiliation':affiliation, 'nick':nick}) - else: - item = ET.Element('{http://jabber.org/protocol/muc#admin}item', {'affiliation':affiliation, 'jid':jid}) - query.append(item) - iq = self.xmpp.make_iq_set(query) - iq['to'] = room - iq['from'] = ifrom - # For now, swallow errors to preserve existing API - try: - result = iq.send() - except IqError: - return False - except IqTimeout: - return False - return True - - def set_role(self, room, nick, role): - """ Change role property of a nick in a room. - Typically, roles are temporary (they last only as long as you are in the - room), whereas affiliations are permanent (they last across groupchat - sessions). - """ - if role not in ('moderator', 'participant', 'visitor', 'none'): - raise TypeError - query = ET.Element('{http://jabber.org/protocol/muc#admin}query') - item = ET.Element('item', {'role':role, 'nick':nick}) - query.append(item) - iq = self.xmpp.make_iq_set(query) - iq['to'] = room - result = iq.send() - if result is False or result['type'] != 'result': - raise ValueError - return True - - def invite(self, room, jid, reason='', mfrom=''): - """ Invite a jid to a room.""" - msg = self.xmpp.make_message(room) - msg['from'] = mfrom - x = ET.Element('{http://jabber.org/protocol/muc#user}x') - invite = ET.Element('{http://jabber.org/protocol/muc#user}invite', {'to': jid}) - if reason: - rxml = ET.Element('{http://jabber.org/protocol/muc#user}reason') - rxml.text = reason - invite.append(rxml) - x.append(invite) - msg.append(x) - self.xmpp.send(msg) - - def leave_muc(self, room, nick, msg='', pfrom=None): - """ Leave the specified room. - """ - if msg: - self.xmpp.send_presence(pshow='unavailable', pto="%s/%s" % (room, nick), pstatus=msg, pfrom=pfrom) - else: - self.xmpp.send_presence(pshow='unavailable', pto="%s/%s" % (room, nick), pfrom=pfrom) - del self.rooms[room] - - def get_room_config(self, room, ifrom=''): - iq = self.xmpp.make_iq_get('http://jabber.org/protocol/muc#owner') - iq['to'] = room - iq['from'] = ifrom - # For now, swallow errors to preserve existing API - try: - result = iq.send() - except IqError: - raise ValueError - except IqTimeout: - raise ValueError - form = result.xml.find('{http://jabber.org/protocol/muc#owner}query/{jabber:x:data}x') - if form is None: - raise ValueError - return self.xmpp.plugin['xep_0004'].build_form(form) - - def cancel_config(self, room, ifrom=None): - query = ET.Element('{http://jabber.org/protocol/muc#owner}query') - x = ET.Element('{jabber:x:data}x', type='cancel') - query.append(x) - iq = self.xmpp.make_iq_set(query) - iq['to'] = room - iq['from'] = ifrom - iq.send() - - def set_room_config(self, room, config, ifrom=''): - query = ET.Element('{http://jabber.org/protocol/muc#owner}query') - config['type'] = 'submit' - query.append(config) - iq = self.xmpp.make_iq_set(query) - iq['to'] = room - iq['from'] = ifrom - iq.send() - - def get_joined_rooms(self): - return self.rooms.keys() - - def get_our_jid_in_room(self, room_jid): - """ Return the jid we're using in a room. - """ - return "%s/%s" % (room_jid, self.our_nicks[room_jid]) - - def get_jid_property(self, room, nick, jid_property): - """ Get the property of a nick in a room, such as its 'jid' or 'affiliation' - If not found, return None. - """ - if room in self.rooms and nick in self.rooms[room] and jid_property in self.rooms[room][nick]: - return self.rooms[room][nick][jid_property] - else: - return None - - def get_roster(self, room): - """ Get the list of nicks in a room. - """ - if room not in self.rooms.keys(): - return None - return self.rooms[room].keys() - - def get_users_by_affiliation(cls, room, affiliation='member', ifrom=None): - if affiliation not in ('outcast', 'member', 'admin', 'owner', 'none'): - raise TypeError - query = ET.Element('{http://jabber.org/protocol/muc#admin}query') - item = ET.Element('{http://jabber.org/protocol/muc#admin}item', {'affiliation': affiliation}) - query.append(item) - iq = cls.xmpp.Iq(sto=room, sfrom=ifrom, stype='get') - iq.append(query) - return iq.send() - - -register_plugin(XEP_0045) diff --git a/slixmpp/plugins/xep_0045/__init__.py b/slixmpp/plugins/xep_0045/__init__.py new file mode 100644 index 00000000..eb13b018 --- /dev/null +++ b/slixmpp/plugins/xep_0045/__init__.py @@ -0,0 +1,14 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2020 "Maxime “pep” Buquet <pep@bouah.net>" + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins import register_plugin +from slixmpp.plugins.xep_0045 import stanza +from slixmpp.plugins.xep_0045.muc import XEP_0045 +from slixmpp.plugins.xep_0045.stanza import MUCPresence, MUCMessage + +register_plugin(XEP_0045) diff --git a/slixmpp/plugins/xep_0045/muc.py b/slixmpp/plugins/xep_0045/muc.py new file mode 100644 index 00000000..f310c03e --- /dev/null +++ b/slixmpp/plugins/xep_0045/muc.py @@ -0,0 +1,382 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + Copyright (C) 2020 "Maxime “pep” Buquet <pep@bouah.net>" + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" +from __future__ import with_statement + +import logging +from typing import ( + List, + Tuple, + Optional, +) + +from slixmpp import ( + Presence, + Message, + Iq, + JID, +) +from slixmpp.plugins import BasePlugin +from slixmpp.xmlstream import register_stanza_plugin, ET +from slixmpp.xmlstream.handler.callback import Callback +from slixmpp.xmlstream.matcher.xpath import MatchXPath +from slixmpp.xmlstream.matcher.xmlmask import MatchXMLMask +from slixmpp.exceptions import IqError, IqTimeout + +from slixmpp.plugins.xep_0045 import stanza +from slixmpp.plugins.xep_0045.stanza import ( + MUCPresence, + MUCJoin, + MUCMessage, + MUCAdminQuery, + MUCAdminItem, + MUCHistory, + MUCOwnerQuery, + MUCOwnerDestroy, +) + + +log = logging.getLogger(__name__) + +AFFILIATIONS = ('outcast', 'member', 'admin', 'owner', 'none') +ROLES = ('moderator', 'participant', 'visitor', 'none') + + +class XEP_0045(BasePlugin): + + """ + Implements XEP-0045 Multi-User Chat + """ + + name = 'xep_0045' + description = 'XEP-0045: Multi-User Chat' + dependencies = {'xep_0030', 'xep_0004'} + stanza = stanza + + def plugin_init(self): + self.rooms = {} + self.our_nicks = {} + # load MUC support in presence stanzas + register_stanza_plugin(Presence, MUCPresence) + register_stanza_plugin(Presence, MUCJoin) + register_stanza_plugin(MUCJoin, MUCHistory) + register_stanza_plugin(Message, MUCMessage) + register_stanza_plugin(Iq, MUCAdminQuery) + register_stanza_plugin(Iq, MUCOwnerQuery) + register_stanza_plugin(MUCOwnerQuery, MUCOwnerDestroy) + register_stanza_plugin(MUCAdminQuery, MUCAdminItem, iterable=True) + + # Register handlers + self.xmpp.register_handler( + Callback( + 'MUCPresence', + MatchXMLMask("<presence xmlns='%s' />" % self.xmpp.default_ns), + self.handle_groupchat_presence, + )) + self.xmpp.register_handler( + Callback( + 'MUCError', + MatchXMLMask("<message xmlns='%s' type='error'><error/></message>" % self.xmpp.default_ns), + self.handle_groupchat_error_message + )) + self.xmpp.register_handler( + Callback( + 'MUCMessage', + MatchXMLMask("<message xmlns='%s' type='groupchat'><body/></message>" % self.xmpp.default_ns), + self.handle_groupchat_message + )) + self.xmpp.register_handler( + Callback( + 'MUCSubject', + MatchXMLMask("<message xmlns='%s' type='groupchat'><subject/></message>" % self.xmpp.default_ns), + self.handle_groupchat_subject + )) + self.xmpp.register_handler( + Callback( + 'MUCConfig', + MatchXMLMask( + "<message xmlns='%s' type='groupchat'>" + "<x xmlns='http://jabber.org/protocol/muc#user'><status/></x>" + "</message>" % self.xmpp.default_ns + ), + self.handle_config_change + )) + self.xmpp.register_handler( + Callback( + 'MUCInvite', + MatchXPath("{%s}message/{%s}x/{%s}invite" % ( + self.xmpp.default_ns, + stanza.NS_USER, + stanza.NS_USER + )), + self.handle_groupchat_invite + )) + + def plugin_end(self): + self.xmpp.plugin['xep_0030'].del_feature(feature=stanza.NS) + + def session_bind(self, jid): + self.xmpp.plugin['xep_0030'].add_feature(stanza.NS) + + def handle_groupchat_invite(self, inv): + """ Handle an invite into a muc. + """ + logging.debug("MUC invite to %s from %s: %s", inv['to'], inv["from"], inv) + if inv['from'] not in self.rooms.keys(): + self.xmpp.event("groupchat_invite", inv) + + def handle_config_change(self, msg): + """Handle a MUC configuration change (with status code).""" + self.xmpp.event('groupchat_config_status', msg) + self.xmpp.event('muc::%s::config_status' % msg['from'].bare , msg) + + def handle_groupchat_presence(self, pr): + """ Handle a presence in a muc. + """ + got_offline = False + got_online = False + if pr['muc']['room'] not in self.rooms.keys(): + return + self.xmpp.roster[pr['from']].ignore_updates = True + entry = pr['muc'].get_stanza_values() + entry['show'] = pr['show'] if pr['show'] in pr.showtypes else None + entry['status'] = pr['status'] + entry['alt_nick'] = pr['nick'] + if pr['type'] == 'unavailable': + if entry['nick'] in self.rooms[entry['room']]: + del self.rooms[entry['room']][entry['nick']] + got_offline = True + else: + if entry['nick'] not in self.rooms[entry['room']]: + got_online = True + self.rooms[entry['room']][entry['nick']] = entry + log.debug("MUC presence from %s/%s : %s", entry['room'],entry['nick'], entry) + self.xmpp.event("groupchat_presence", pr) + self.xmpp.event("muc::%s::presence" % entry['room'], pr) + if got_offline: + self.xmpp.event("muc::%s::got_offline" % entry['room'], pr) + if got_online: + self.xmpp.event("muc::%s::got_online" % entry['room'], pr) + + def handle_groupchat_message(self, msg: Message) -> None: + """ Handle a message event in a muc. + """ + self.xmpp.event('groupchat_message', msg) + self.xmpp.event("muc::%s::message" % msg['from'].bare, msg) + + def handle_groupchat_error_message(self, msg): + """ Handle a message error event in a muc. + """ + self.xmpp.event('groupchat_message_error', msg) + self.xmpp.event("muc::%s::message_error" % msg['from'].bare, msg) + + + def handle_groupchat_subject(self, msg: Message) -> None: + """ Handle a message coming from a muc indicating + a change of subject (or announcing it when joining the room) + """ + # See poezio#3452. A message containing subject _and_ (body or thread) + # is not a subject change. + if msg['body'] or msg['thread']: + return None + self.xmpp.event('groupchat_subject', msg) + + def jid_in_room(self, room: JID, jid: JID) -> bool: + for nick in self.rooms[room]: + entry = self.rooms[room][nick] + if entry is not None and entry['jid'].full == jid: + return True + return False + + def get_nick(self, room: JID, jid: JID) -> Optional[str]: + for nick in self.rooms[room]: + entry = self.rooms[room][nick] + if entry is not None and entry['jid'].full == jid: + return nick + + def join_muc(self, room: JID, nick: str, maxhistory="0", password='', + pstatus='', pshow='', pfrom=''): + """ Join the specified room, requesting 'maxhistory' lines of history. + """ + stanza = self.xmpp.make_presence( + pto="%s/%s" % (room, nick), pstatus=pstatus, + pshow=pshow, pfrom=pfrom + ) + stanza.enable('muc_join') + if password: + stanza['muc_join']['password'] = password + if maxhistory: + if maxhistory == "0": + stanza['muc_join']['history']['maxchars'] = '0' + else: + stanza['muc_join']['history']['maxstanzas'] = str(maxhistory) + self.xmpp.send(stanza) + self.rooms[room] = {} + self.our_nicks[room] = nick + + async def destroy(self, room: JID, reason='', altroom='', *, + ifrom: Optional[JID] = None, **iqkwargs) -> Iq: + iq = self.xmpp.make_iq_set(ifrom=ifrom, ito=room) + iq.enable('mucowner_query') + iq['mucowner_query'].enable('destroy') + if altroom: + iq['mucowner_query']['destroy']['jid'] = altroom + if reason: + iq['mucowner_query']['destroy']['reason'] = reason + await iq.send(**iqkwargs) + + async def set_affiliation(self, room: JID, jid: Optional[JID] = None, nick: Optional[str] = None, *, affiliation: str, + ifrom: Optional[JID] = None, **iqkwargs): + """ Change room affiliation.""" + if affiliation not in AFFILIATIONS: + raise ValueError('%s is not a valid affiliation' % affiliation) + if not any((jid, nick)): + raise ValueError('One of jid or nick must be set') + iq = self.xmpp.make_iq_set(ito=room, ifrom=ifrom) + iq.enable('mucadmin_query') + item = MUCAdminItem() + item['affiliation'] = affiliation + if nick: + item['nick'] = nick + if jid: + item['jid'] = jid + iq['mucadmin_query'].append(item) + await iq.send(**iqkwargs) + + async def set_role(self, room: JID, nick: str, role: str, *, + ifrom: Optional[JID] = None, **iqkwargs) -> Iq: + """ Change role property of a nick in a room. + Typically, roles are temporary (they last only as long as you are in the + room), whereas affiliations are permanent (they last across groupchat + sessions). + """ + if role not in ROLES: + raise ValueError("Role %s does not exist" % role) + iq = self.xmpp.make_iq_set(ito=room, ifrom=ifrom) + iq.enable('mucadmin_query') + item = MUCAdminItem() + item['role'] = role + item['nick'] = nick + iq['mucadmin_query'].append(item) + await iq.send(**iqkwargs) + + def invite(self, room: JID, jid: JID, reason='', *, + mfrom: Optional[JID] = None): + """ Invite a jid to a room.""" + msg = self.xmpp.make_message(room, mfrom=mfrom) + msg.enable('muc') + msg['muc']['invite'] = jid + if reason: + msg['muc']['invite']['reason'] = reason + self.xmpp.send(msg) + + def leave_muc(self, room: JID, nick: str, msg='', pfrom=None): + """ Leave the specified room. + """ + if msg: + self.xmpp.send_presence(pshow='unavailable', pto="%s/%s" % (room, nick), pstatus=msg, pfrom=pfrom) + else: + self.xmpp.send_presence(pshow='unavailable', pto="%s/%s" % (room, nick), pfrom=pfrom) + del self.rooms[room] + + + async def get_room_config(self, room: JID, ifrom=''): + """Get the room config form in 0004 plugin format """ + iq = self.xmpp.make_iq_get(stanza.NS_OWNER, ito=room, ifrom=ifrom) + # For now, swallow errors to preserve existing API + result = await iq.send() + form = result.xml.find('{http://jabber.org/protocol/muc#owner}query/{jabber:x:data}x') + if form is None: + raise ValueError("Configuration form not found") + return self.xmpp.plugin['xep_0004'].build_form(form) + + async def cancel_config(self, room: JID, *, + ifrom: Optional[JID] = None, **iqkwargs) -> Iq: + """Cancel a requested config form""" + query = MUCOwnerQuery() + x = ET.Element('{jabber:x:data}x', type='cancel') + query.append(x) + iq = self.xmpp.make_iq_set(query, ito=room, ifrom=ifrom) + return await iq.send(**iqkwargs) + + async def set_room_config(self, room: JID, config, *, + ifrom: Optional[JID] = None, **iqkwargs) -> Iq: + """Send a room config form""" + query = MUCOwnerQuery() + config['type'] = 'submit' + query.append(config) + iq = self.xmpp.make_iq_set(query, ito=room, ifrom=ifrom) + return await iq.send(**iqkwargs) + + async def get_affiliation_list(self, room: JID, affiliation: str, *, + ifrom: Optional[JID] = None, **iqkwargs) -> List[JID]: + """"Get a list of JIDs with the specified affiliation""" + iq = self.xmpp.make_iq_get(stanza.NS_ADMIN, ito=room, ifrom=ifrom) + iq['mucadmin_query']['item']['affiliation'] = affiliation + result = await iq.send(**iqkwargs) + return [item['jid'] for item in result['mucadmin_query']] + + async def get_roles_list(self, room: JID, role: str, *, + ifrom: Optional[JID] = None, **iqkwargs) -> List[str]: + """"Get a list of JIDs with the specified role""" + iq = self.xmpp.make_iq_get(stanza.NS_ADMIN, ito=room, ifrom=ifrom) + iq['mucadmin_query']['item']['role'] = role + result = await iq.send(**iqkwargs) + return [item['nick'] for item in result['mucadmin_query']] + + async def send_affiliation_list(self, room: JID, affiliations: List[Tuple[JID, str]], *, + ifrom: Optional[JID] = None, **iqkwargs) -> Iq: + """Send an affiliation delta list""" + iq = self.xmpp.make_iq_set(ito=room, ifrom=ifrom) + for jid, affiliation in affiliations: + item = MUCAdminItem() + item['jid'] = jid + item['affiliation'] = affiliation + iq['mucadmin_query'].append(item) + return await iq.send(**iqkwargs) + + async def send_role_list(self, room: JID, roles: List[Tuple[str, str]], *, + ifrom: Optional[JID], **iqkwargs) -> Iq: + """Send a role delta list""" + iq = self.xmpp.make_iq_set(ito=room, ifrom=ifrom) + for nick, affiliation in roles: + item = MUCAdminItem() + item['nick'] = nick + item['affiliation'] = affiliation + iq['mucadmin_query'].append(item) + return await iq.send(**iqkwargs) + + def get_joined_rooms(self) -> List[JID]: + return self.rooms.keys() + + def get_our_jid_in_room(self, room_jid: JID) -> str: + """ Return the jid we're using in a room. + """ + return "%s/%s" % (room_jid, self.our_nicks[room_jid]) + + def get_jid_property(self, room, nick, jid_property): + """ Get the property of a nick in a room, such as its 'jid' or 'affiliation' + If not found, return None. + """ + if room in self.rooms and nick in self.rooms[room] and jid_property in self.rooms[room][nick]: + return self.rooms[room][nick][jid_property] + else: + return None + + def get_roster(self, room: JID) -> List[str]: + """ Get the list of nicks in a room. + """ + if room not in self.rooms.keys(): + return None + return self.rooms[room].keys() + + def get_users_by_affiliation(self, room: JID, affiliation='member', *, ifrom: Optional[JID] = None): + # Preserve old API + if affiliation not in AFFILIATIONS: + raise TypeError + return self.get_affiliation_list(room, affiliation, ifrom=ifrom) diff --git a/slixmpp/plugins/xep_0045/stanza.py b/slixmpp/plugins/xep_0045/stanza.py new file mode 100644 index 00000000..9756790b --- /dev/null +++ b/slixmpp/plugins/xep_0045/stanza.py @@ -0,0 +1,198 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + Copyright (C) 2020 "Maxime “pep” Buquet <pep@bouah.net>" + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import logging +from slixmpp.xmlstream import ElementBase, ET, JID + + +log = logging.getLogger(__name__) + +NS = 'http://jabber.org/protocol/muc' +NS_USER = 'http://jabber.org/protocol/muc#user' +NS_ADMIN = 'http://jabber.org/protocol/muc#admin' +NS_OWNER = 'http://jabber.org/protocol/muc#owner' + + +class MUCBase(ElementBase): + name = 'x' + namespace = NS_USER + plugin_attrib = 'muc' + interfaces = {'affiliation', 'role', 'jid', 'nick', 'room'} + + def get_item_attr(self, attr, default: str): + item = self.xml.find(f'{{{NS_USER}}}item') + if item is None: + return default + return item.get(attr) + + def set_item_attr(self, attr, value: str): + item = self.xml.find(f'{{{NS_USER}}}item') + if item is None: + item = ET.Element(f'{{{NS_USER}}}item') + self.xml.append(item) + item.attrib[attr] = value + return item + + def del_item_attr(self, attr): + item = self.xml.find(f'{{{NS_USER}}}item') + if item is not None and attr in item.attrib: + del item.attrib[attr] + + def get_affiliation(self): + return self.get_item_attr('affiliation', '') + + def set_affiliation(self, value): + self.set_item_attr('affiliation', value) + return self + + def del_affiliation(self): + # TODO: set default affiliation + self.del_item_attr('affiliation') + return self + + def get_jid(self): + return JID(self.get_item_attr('jid', '')) + + def set_jid(self, value): + if not isinstance(value, str): + value = str(value) + self.set_item_attr('jid', value) + return self + + def del_jid(self): + self.del_item_attr('jid') + return self + + def get_role(self): + return self.get_item_attr('role', '') + + def set_role(self, value): + # TODO: check for valid role + self.set_item_attr('role', value) + return self + + def del_role(self): + # TODO: set default role + self.del_item_attr('role') + return self + + def get_nick(self): + return self.parent()['from'].resource + + def get_room(self): + return self.parent()['from'].bare + + def set_nick(self, value): + log.warning( + "Cannot set nick through the %s plugin.", + self.__class__.__name__, + ) + return self + + def set_room(self, value): + log.warning( + "Cannot set room through the %s plugin.", + self.__class__.__name__, + ) + return self + + def del_nick(self): + log.warning( + "Cannot delete nick through the %s plugin.", + self.__class__.__name__, + ) + return self + + def del_room(self): + log.warning( + "Cannot delete room through the %s plugin.", + self.__class__.__name__, + ) + return self + + +class MUCPresence(MUCBase): + ''' + A MUC Presence + + <presence from='foo@muc/user1' type='unavailable'> + <x xmlns='http://jabber.org/protocol/muc#user'> + <item affiliation='none' + role='none' + nick='newnick2' + jid='some@jid'/> + <status code='303'/> + </x> + </presence> + ''' + + +class MUCMessage(MUCBase): + ''' + A MUC Message + + <message from='foo@muc/user1' type='groupchat' id='someid'> + <body>Foo</body> + <x xmlns='http://jabber.org/protocol/muc#user'> + <item affiliation='none' + role='none' + nick='newnick2' + jid='some@jid'/> + </x> + </message> + ''' + +class MUCJoin(ElementBase): + name = 'x' + namespace = NS + plugin_attrib = 'muc_join' + interfaces = {'password'} + sub_interfaces = {'password'} + + +class MUCInvite(ElementBase): + name = 'invite' + plugin_attrib = 'invite' + namespace = NS_USER + interfaces = {'to', 'reason'} + sub_interfaces = {'reason'} + + +class MUCHistory(ElementBase): + name = 'history' + plugin_attrib = 'history' + namespace = NS + interfaces = {'maxchars', 'maxstanzas', 'since', 'seconds'} + + +class MUCOwnerQuery(ElementBase): + name = 'query' + plugin_attrib = 'mucowner_query' + namespace = NS_OWNER + + +class MUCOwnerDestroy(ElementBase): + name = 'destroy' + plugin_attrib = 'destroy' + interfaces = {'reason', 'jid'} + sub_interfaces = {'reason'} + + +class MUCAdminQuery(ElementBase): + name = 'query' + plugin_attrib = 'mucadmin_query' + namespace = NS_ADMIN + + +class MUCAdminItem(ElementBase): + namespace = NS_ADMIN + name = 'item' + plugin_attrib = 'item' + interfaces = {'role', 'affiliation', 'nick', 'jid'} + diff --git a/slixmpp/plugins/xep_0047/stream.py b/slixmpp/plugins/xep_0047/stream.py index b3767265..535ba82b 100644 --- a/slixmpp/plugins/xep_0047/stream.py +++ b/slixmpp/plugins/xep_0047/stream.py @@ -36,7 +36,7 @@ class IBBytestream(object): raise socket.error if len(data) > self.block_size: data = data[:self.block_size] - self.send_seq = (self.send_seq + 1) % 65535 + self.send_seq = (self.send_seq + 1) % 65536 seq = self.send_seq if self.use_messages: msg = self.xmpp.Message() @@ -72,7 +72,7 @@ class IBBytestream(object): def _recv_data(self, stanza): new_seq = stanza['ibb_data']['seq'] - if new_seq != (self.recv_seq + 1) % 65535: + if new_seq != (self.recv_seq + 1) % 65536: self.close() raise XMPPError('unexpected-request') self.recv_seq = new_seq diff --git a/slixmpp/plugins/xep_0115/caps.py b/slixmpp/plugins/xep_0115/caps.py index 749b74bd..0acfa83a 100644 --- a/slixmpp/plugins/xep_0115/caps.py +++ b/slixmpp/plugins/xep_0115/caps.py @@ -157,10 +157,12 @@ class XEP_0115(BasePlugin): self.assign_verstring(pres['from'], ver) return + ifrom = pres['to'] if self.xmpp.is_component else None + if pres['caps']['hash'] not in self.hashes: try: log.debug("Unknown caps hash: %s", pres['caps']['hash']) - self.xmpp['xep_0030'].get_info(jid=pres['from']) + self.xmpp['xep_0030'].get_info(jid=pres['from'], ifrom=ifrom) return except XMPPError: return @@ -169,7 +171,8 @@ class XEP_0115(BasePlugin): try: node = '%s#%s' % (pres['caps']['node'], ver) caps = await self.xmpp['xep_0030'].get_info(pres['from'], node, - coroutine=True) + coroutine=True, + ifrom=ifrom) if isinstance(caps, Iq): caps = caps['disco_info'] diff --git a/slixmpp/plugins/xep_0363/http_upload.py b/slixmpp/plugins/xep_0363/http_upload.py index a833a9c9..04b066cd 100644 --- a/slixmpp/plugins/xep_0363/http_upload.py +++ b/slixmpp/plugins/xep_0363/http_upload.py @@ -138,6 +138,7 @@ class XEP_0363(BasePlugin): basename = os.path.basename(filename) slot_iq = await self.request_slot(self.upload_service, basename, size, content_type, ifrom, timeout, + callback=callback, timeout_callback=timeout_callback) slot = slot_iq['http_upload_slot'] diff --git a/slixmpp/plugins/xep_0369/__init__.py b/slixmpp/plugins/xep_0369/__init__.py new file mode 100644 index 00000000..2fa3a0ad --- /dev/null +++ b/slixmpp/plugins/xep_0369/__init__.py @@ -0,0 +1,13 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2020 Mathieu Pasquet <mathieui@mathieui.net> + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin +from slixmpp.plugins.xep_0369.stanza import * +from slixmpp.plugins.xep_0369.mix_core import XEP_0369 + +register_plugin(XEP_0369) diff --git a/slixmpp/plugins/xep_0369/mix_core.py b/slixmpp/plugins/xep_0369/mix_core.py new file mode 100644 index 00000000..598a97f4 --- /dev/null +++ b/slixmpp/plugins/xep_0369/mix_core.py @@ -0,0 +1,288 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2020 Mathieu Pasquet <mathieui@mathieui.net> + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" +from typing import ( + Any, + Dict, + List, + Optional, + Set, + Tuple, +) + +from datetime import datetime +from slixmpp import JID, Iq +from slixmpp.exceptions import IqError, IqTimeout +from slixmpp.plugins import BasePlugin +from slixmpp.plugins.xep_0369 import stanza +from slixmpp.xmlstream.handler import Callback +from slixmpp.xmlstream.matcher import MatchXPath + +try: + from typing import TypedDict + InfoType = TypedDict( + 'InfoType', + { + 'Name': str, + 'Description': str, + 'Contact': Optional[List[JID]], + 'modified': datetime + }, + total=False, + ) +except ImportError: + # Placeholder until we drop python < 3.8 + InfoType = Dict[str, Any] + + +BASE_NODES = [ + 'urn:xmpp:mix:nodes:messages', + 'urn:xmpp:mix:nodes:participants', + 'urn:xmpp:mix:nodes:info', +] + + +class XEP_0369(BasePlugin): + '''XEP-0369: MIX-CORE''' + + name = 'xep_0369' + description = 'MIX-CORE' + dependencies = {'xep_0030', 'xep_0060', 'xep_0082', 'xep_0004'} + stanza = stanza + namespace = stanza.NS + + def plugin_init(self) -> None: + stanza.register_plugins() + self.xmpp.register_handler( + Callback( + "MIX message received", + MatchXPath('{%s}message[@type="groupchat"]/{%s}mix' % ( + self.xmpp.default_ns, self.namespace + )), + self._handle_mix_message, + ) + ) + + def _handle_mix_message(self, message): + self.xmpp.event('mix_message', message) + + def session_bind(self, jid): + self.xmpp.plugin['xep_0030'].add_feature(stanza.NS) + + def plugin_end(self): + self.xmpp.plugin['xep_0030'].del_feature(feature=stanza.NS) + + async def get_channel_info(self, channel: JID) -> InfoType: + """" + Get the contents of the channel info node. + :param JID channel: The MIX channel + :returns: a dict containing the last modified time and form contents + (Name, Description, Contact per the spec, YMMV) + """ + info = await self.xmpp['xep_0060'].get_items(channel, 'urn:xmpp:mix:nodes:info') + for item in info['pubsub']['items']: + time = item['id'] + fields = item['form'].get_values() + del fields['FORM_TYPE'] + fields['modified'] = self.xmpp['xep_0082'].parse(time) + contact = fields.get('Contact') + if contact: + if isinstance(contact, str): + contact = [contact] + elif isinstance(contact, list): + contact = [JID(cont) for cont in contact] + fields['Contact'] = contact + return fields + + async def join_channel(self, channel: JID, nick: str, subscribe: Optional[Set[str]] = None, *, + ifrom: Optional[JID] = None, **iqkwargs) -> Set[str]: + """ + Join a MIX channel. + + :param JID channel: JID of the MIX channel + :param str nick: Desired nickname on that channel + :param Set[str] subscribe: Set of notes to subscribe to when joining. + If empty, all nodes will be subscribed by default. + + :rtype: Set[str] + :return: The nodes that failed to subscribe, if any + """ + if not subscribe: + subscribe = set(BASE_NODES) + iq = self.xmpp.make_iq_set(ito=channel, ifrom=ifrom) + iq['mix_join']['nick'] = nick + for node in subscribe: + sub = stanza.Subscribe() + sub['node'] = node + iq['mix_join']['subscribe'].append(sub) + result = await iq.send(**iqkwargs) + result_nodes = {sub['node'] for sub in result['mix_join']} + return result_nodes.difference(subscribe) + + async def update_subscription(self, channel: JID, + subscribe: Optional[Set[str]] = None, + unsubscribe: Optional[Set[str]] = None, *, + ifrom: Optional[JID] = None, **iqkwargs) -> Tuple[Set[str], Set[str]]: + """ + Update a MIX channel subscription. + + :param JID channel: JID of the MIX channel + :param Set[str] subscribe: Set of notes to subscribe to additionally. + :param Set[str] unsubscribe: Set of notes to unsubscribe from. + :rtype: Tuple[Set[str], Set[str]] + :return: A tuple containing the set of nodes that failed to subscribe + and the set of nodes that failed to unsubscribe. + """ + if not subscribe and not unsubscribe: + raise ValueError("No nodes were provided.") + unsubscribe = unsubscribe or set() + subscribe = subscribe or set() + iq = self.xmpp.make_iq_set(ito=channel, ifrom=ifrom) + iq.enable('mix_updatesub') + for node in subscribe: + sub = stanza.Subscribe() + sub['node'] = node + iq['mix_updatesub'].append(sub) + for node in unsubscribe: + unsub = stanza.Unsubscribe() + unsub['node'] = node + iq['mix_updatesub'].append(unsub) + result = await iq.send(**iqkwargs) + for item in result['mix_updatesub']: + if isinstance(item, stanza.Subscribe): + subscribe.discard(item['node']) + elif isinstance(item, stanza.Unsubscribe): + unsubscribe.discard(item['node']) + return (subscribe, unsubscribe) + + async def leave_channel(self, channel: JID, *, + ifrom: Optional[JID] = None, **iqkwargs) -> None: + """" + Leave a MIX channel + :param JID channel: JID of the channel to leave + """ + iq = self.xmpp.make_iq_set(ito=channel, ifrom=ifrom) + iq.enable('mix_leave') + await iq.send(**iqkwargs) + + async def set_nick(self, channel: JID, nick: str, *, + ifrom: Optional[JID] = None, **iqkwargs) -> str: + """ + Set your nick on a channel. The returned nick MAY be different + from the one provided, depending on service configuration. + :param JID channel: MIX channel JID + :param str nick: desired nick + :rtype: str + :return: The nick saved on the MIX channel + """ + + iq = self.xmpp.make_iq_set(ito=channel, ifrom=ifrom) + iq['mix_setnick']['nick'] = nick + result = await iq.send(**iqkwargs) + result_nick = result['mix_setnick']['nick'] + return result_nick + + async def can_create_channel(self, service: JID) -> bool: + """ + Check if the current user can create a channel on the MIX service + + :param JID service: MIX service jid + :rtype: bool + """ + results_stanza = await self.xmpp['xep_0030'].get_info(service.server) + features = results_stanza['disco_info']['features'] + return 'urn:xmpp:mix:core:1#create-channel' in features + + async def create_channel(self, service: JID, channel: Optional[str] = None, *, + ifrom: Optional[JID] = None, **iqkwargs) -> str: + """ + Create a MIX channel. + + :param JID service: MIX service JID + :param Optional[str] channel: Channel name (or leave empty to let + the service generate it) + :returns: The channel name, as created by the service + """ + if '#' in channel: + raise ValueError("A channel name cannot contain hashes") + iq = self.xmpp.make_iq_set(ito=service.server, ifrom=ifrom) + iq.enable('mix_create') + if channel is not None: + iq['mix_create']['channel'] = channel + result = await iq.send(**iqkwargs) + return result['mix_create']['channel'] + + async def destroy_channel(self, channel: JID, *, + ifrom: Optional[JID] = None, **iqkwargs): + """ + Destroy a MIX channel. + :param JID channel: MIX channelJID + """ + iq = self.xmpp.make_iq_set(ito=channel.server, ifrom=ifrom) + iq['mix_destroy'] = channel.user + await iq.send(**iqkwargs) + + async def list_mix_nodes(self, channel: JID, + ifrom: Optional[JID] = None, **discokwargs) -> Set[str]: + """ + List mix nodes for a channel. + + :param JID channel: The MIX channel + :returns: List of nodes available + """ + result = await self.xmpp['xep_0030'].get_items( + channel, + node='mix', + ifrom=ifrom, + **discokwargs, + ) + nodes = set() + for item in result['disco_items']: + nodes.add(item['node']) + return nodes + + async def list_participants(self, channel: JID, *, + ifrom: Optional[JID] = None, **pubsubkwargs) -> List[Tuple[str, str, Optional[JID]]]: + """ + List the participants of a MIX channel + :param JID channel: The MIX channel + + :returns: A list of tuples containing the participant id, nick, and jid (if available) + """ + info = await self.xmpp['xep_0060'].get_items( + channel, + 'urn:xmpp:mix:nodes:participants', + ifrom=ifrom, + **pubsubkwargs + ) + participants = list() + for item in info['pubsub']['items']: + identifier = item['id'] + nick = item['mix_participant']['nick'] + jid = item['mix_participant']['jid'] or None + participants.append( + (identifier, nick, jid), + ) + return participants + + async def list_channels(self, service: JID, *, + ifrom: Optional[JID] =None, **discokwargs) -> List[Tuple[JID, str]]: + """ + List the channels on a MIX service + + :param JID service: MIX service JID + :returns: A list of channels with their JID and name + """ + results_stanza = await self.xmpp['xep_0030'].get_items( + service.server, + ifrom=ifrom, + **discokwargs, + ) + results = [] + for result in results_stanza['disco_items']: + results.append((result['jid'], result['name'])) + return results diff --git a/slixmpp/plugins/xep_0369/stanza.py b/slixmpp/plugins/xep_0369/stanza.py new file mode 100644 index 00000000..ca64b2c4 --- /dev/null +++ b/slixmpp/plugins/xep_0369/stanza.py @@ -0,0 +1,121 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2020 Mathieu Pasquet <mathieui@mathieui.net> + This file is part of Slixmpp. + + See the file LICENSE for copying permissio +""" + +import xml.etree.ElementTree as ET +from slixmpp import JID +from slixmpp.stanza import ( + Iq, + Message, +) +from slixmpp.xmlstream import ( + ElementBase, + register_stanza_plugin, +) + +from slixmpp.plugins.xep_0004.stanza import ( + Form, +) +from slixmpp.plugins.xep_0060.stanza import ( + EventItem, + Item, +) + +NS = 'urn:xmpp:mix:core:1' + + +class MIX(ElementBase): + name = 'mix' + namespace = NS + plugin_attrib = 'mix' + interfaces = {'nick', 'jid'} + sub_interfaces = {'nick', 'jid'} + + +class Setnick(ElementBase): + name = 'setnick' + namespace = NS + plugin_attrib = 'mix_setnick' + interfaces = {'nick'} + sub_interfaces = {'nick'} + + +class Join(ElementBase): + namespace = NS + name = 'join' + plugin_attrib = 'mix_join' + interfaces = {'nick', 'id'} + sub_interfaces = {'nick'} + + +class Leave(ElementBase): + namespace = NS + name = 'leave' + plugin_attrib = 'mix_leave' + + +class Subscribe(ElementBase): + namespace = NS + name = 'subscribe' + plugin_attrib = 'subscribe' + interfaces = {'node'} + + +class Unsubscribe(ElementBase): + namespace = NS + name = 'unsubscribe' + plugin_attrib = 'unsubscribe' + interfaces = {'node'} + +class UpdateSubscription(ElementBase): + namespace = NS + name = 'update-subscription' + plugin_attrib = 'mix_updatesub' + interfaces = {'jid'} + + +class Create(ElementBase): + name = 'create' + plugin_attrib = 'mix_create' + namespace = NS + interfaces = {'channel'} + + +class Participant(ElementBase): + namespace = NS + name = 'participant' + plugin_attrib = 'mix_participant' + interfaces = {'nick', 'jid'} + sub_interfaces = {'nick', 'jid'} + + +class Destroy(ElementBase): + name = 'destroy' + plugin_attrib = 'mix_destroy' + namespace = NS + interfaces = {'channel'} + + +def register_plugins(): + register_stanza_plugin(Item, Form) + register_stanza_plugin(EventItem, Form) + + register_stanza_plugin(EventItem, Participant) + register_stanza_plugin(Item, Participant) + + register_stanza_plugin(Join, Subscribe, iterable=True) + register_stanza_plugin(Iq, Join) + + register_stanza_plugin(UpdateSubscription, Subscribe, iterable=True) + register_stanza_plugin(UpdateSubscription, Unsubscribe, iterable=True) + register_stanza_plugin(Iq, UpdateSubscription) + + register_stanza_plugin(Iq, Leave) + register_stanza_plugin(Iq, Create) + register_stanza_plugin(Iq, Setnick) + + register_stanza_plugin(Message, MIX) diff --git a/slixmpp/plugins/xep_0403/__init__.py b/slixmpp/plugins/xep_0403/__init__.py new file mode 100644 index 00000000..0526276e --- /dev/null +++ b/slixmpp/plugins/xep_0403/__init__.py @@ -0,0 +1,13 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2020 Mathieu Pasquet <mathieui@mathieui.net> + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin +from slixmpp.plugins.xep_0403.stanza import * +from slixmpp.plugins.xep_0403.mix_presence import XEP_0403 + +register_plugin(XEP_0403) diff --git a/slixmpp/plugins/xep_0403/mix_presence.py b/slixmpp/plugins/xep_0403/mix_presence.py new file mode 100644 index 00000000..995439b9 --- /dev/null +++ b/slixmpp/plugins/xep_0403/mix_presence.py @@ -0,0 +1,47 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2020 Mathieu Pasquet <mathieui@mathieui.net> + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" +from typing import ( + Optional, + Set, +) + +from slixmpp import JID, Iq +from slixmpp.exceptions import IqError, IqTimeout +from slixmpp.plugins import BasePlugin +from slixmpp.plugins.xep_0403 import stanza +from slixmpp.xmlstream.matcher import MatchXPath +from slixmpp.xmlstream.handler import Callback + + +NODES = [ + 'urn:xmpp:mix:nodes:presence' +] + + +class XEP_0403(BasePlugin): + '''XEP-0403: MIX-Presence''' + + name = 'xep_0403' + description = 'MIX-Presence' + dependencies = {'xep_0369'} + stanza = stanza + namespace = stanza.NS + + def plugin_init(self) -> None: + stanza.register_plugins() + + self.xmpp.register_handler( + Callback( + 'MIX Presence received', + MatchXPath('{%s}presence/{%s}mix' % (self.xmpp.default_ns, stanza.NS)), + self._handle_mix_presence, + ) + ) + + def _handle_mix_presence(self, presence): + self.xmpp.event('mix_presence', presence) diff --git a/slixmpp/plugins/xep_0403/stanza.py b/slixmpp/plugins/xep_0403/stanza.py new file mode 100644 index 00000000..3e5b9cde --- /dev/null +++ b/slixmpp/plugins/xep_0403/stanza.py @@ -0,0 +1,37 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2020 Mathieu Pasquet <mathieui@mathieui.net> + This file is part of Slixmpp. + + See the file LICENSE for copying permissio +""" + +from xml.etree import ElementTree as ET +from slixmpp import JID +from slixmpp.stanza import Presence +from slixmpp.xmlstream import ( + register_stanza_plugin, + ElementBase, +) + +from slixmpp.plugins.xep_0060.stanza import ( + Item, + EventItem, +) + + +NS = 'urn:xmpp:mix:presence:0' + + +class MIXPresence(ElementBase): + namespace = NS + name = 'mix' + plugin_attrib = 'mix' + interfaces = {'jid', 'nick'} + sub_interfaces = {'jid', 'nick'} + + +def register_plugins(): + register_stanza_plugin(Presence, MIXPresence) + register_stanza_plugin(Item, Presence) + register_stanza_plugin(EventItem, Presence) diff --git a/slixmpp/plugins/xep_0404/__init__.py b/slixmpp/plugins/xep_0404/__init__.py new file mode 100644 index 00000000..21dd6814 --- /dev/null +++ b/slixmpp/plugins/xep_0404/__init__.py @@ -0,0 +1,13 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2020 Mathieu Pasquet <mathieui@mathieui.net> + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin +from slixmpp.plugins.xep_0404.stanza import Participant +from slixmpp.plugins.xep_0404.mix_anon import XEP_0404 + +register_plugin(XEP_0404) diff --git a/slixmpp/plugins/xep_0404/mix_anon.py b/slixmpp/plugins/xep_0404/mix_anon.py new file mode 100644 index 00000000..d8c42381 --- /dev/null +++ b/slixmpp/plugins/xep_0404/mix_anon.py @@ -0,0 +1,101 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2020 Mathieu Pasquet <mathieui@mathieui.net> + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" +from typing import ( + Dict, + Optional, + Set, + Tuple, +) + +from slixmpp import JID, Message, Iq +from slixmpp.exceptions import IqError, IqTimeout +from slixmpp.plugins import BasePlugin +from slixmpp.xmlstream import register_stanza_plugin +from slixmpp.xmlstream.matcher import MatchXPath +from slixmpp.xmlstream.handler import Callback +from slixmpp.plugins.xep_0404 import stanza +from slixmpp.plugins.xep_0004.stanza import Form + + +NODES = [ + 'urn:xmpp:mix:nodes:jidmap', +] + + +class XEP_0404(BasePlugin): + '''XEP-0404: MIX JID Hidden Channels''' + + name = 'xep_0404' + description = 'MIX-ANON' + dependencies = {'xep_0369'} + stanza = stanza + namespace = stanza.NS + + def plugin_init(self) -> None: + stanza.register_plugins() + + async def get_anon_raw(self, channel: JID, *, + ifrom: Optional[JID] = None, **pubsubkwargs) -> Iq: + """ + Get the jid-participant mapping result (raw). + :param JID channel: MIX channel JID + """ + return await self.xmpp['xep_0030'].get_items( + channel.bare, + ifrom=ifrom, + **pubsubkwargs + ) + + async def get_anon_by_jid(self, channel: JID, *, + ifrom: Optional[JID] = None, **pubsubkwargs) -> Dict[JID, str]: + """ + Get the jid-participant mapping, by JID + + :param JID channel: MIX channel JID + """ + raw = await self.get_anon_raw(channel, ifrom=ifrom, **pubsubkwargs) + mapping = {} + for item in raw['pubsub']['items']: + mapping[item['anon_participant']['jid']] = item['id'] + return mapping + + async def get_anon_by_id(self, channel: JID, *, + ifrom: Optional[JID] = None, **pubsubkwargs) -> Dict[str, JID]: + """ + Get the jid-participant mapping, by participant id + + :param JID channel: MIX channel JID + """ + raw = await self.get_anon_raw(channel, ifrom=ifrom, **pubsubkwargs) + mapping = {} + for item in raw['pubsub']['items']: + mapping[item['id']] = item['anon_participant']['jid'] + return mapping + + async def get_preferences(self, channel: JID, *, + ifrom: Optional[JID] = None, **iqkwargs) -> Form: + """ + Get channel preferences with default values. + :param JID channel: MIX channel JID + """ + iq = self.xmpp.make_iq_get(ito=channel.bare, ifrom=ifrom) + iq.enable('user_preference') + prefs_stanza = await iq.send(**iqkwargs) + return prefs_stanza['user_preference']['form'] + + async def set_preferences(self, channel: JID, form: Form, *, + ifrom: Optional[JID] = None, **iqkwargs) -> Form: + """ + Set channel preferences + :param JID channel: MIX channel JID + :param Form form: A 0004 form with updated preferences + """ + iq = self.xmpp.make_iq_set(ito=channel.bare, ifrom=ifrom) + iq['user_preference']['form'] = form + prefs_result = await iq.send(**iqkwargs) + return prefs_result['user_preference']['form'] diff --git a/slixmpp/plugins/xep_0404/stanza.py b/slixmpp/plugins/xep_0404/stanza.py new file mode 100644 index 00000000..9bb9308e --- /dev/null +++ b/slixmpp/plugins/xep_0404/stanza.py @@ -0,0 +1,43 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2020 Mathieu Pasquet <mathieui@mathieui.net> + This file is part of Slixmpp. + + See the file LICENSE for copying permissio +""" + +from slixmpp.xmlstream import ( + ElementBase, + register_stanza_plugin, +) +from slixmpp import Iq + +from slixmpp.plugins.xep_0004.stanza import Form +from slixmpp.plugins.xep_0060.stanza import ( + EventItem, + Item, +) + +NS = 'urn:xmpp:mix:anon:0' + + +class Participant(ElementBase): + namespace = NS + name = 'participant' + plugin_attrib = 'anon_participant' + interfaces = {'jid'} + sub_interfaces = {'jid'} + + +class UserPreference(ElementBase): + namespace = NS + name = 'user-preference' + plugin_attrib = 'user_preference' + + +def register_plugins(): + register_stanza_plugin(EventItem, Participant) + register_stanza_plugin(Item, Participant) + + register_stanza_plugin(Iq, UserPreference) + register_stanza_plugin(UserPreference, Form) diff --git a/slixmpp/plugins/xep_0405/__init__.py b/slixmpp/plugins/xep_0405/__init__.py new file mode 100644 index 00000000..0a877682 --- /dev/null +++ b/slixmpp/plugins/xep_0405/__init__.py @@ -0,0 +1,13 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2020 Mathieu Pasquet <mathieui@mathieui.net> + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin +from slixmpp.plugins.xep_0405.stanza import * +from slixmpp.plugins.xep_0405.mix_pam import XEP_0405 + +register_plugin(XEP_0405) diff --git a/slixmpp/plugins/xep_0405/mix_pam.py b/slixmpp/plugins/xep_0405/mix_pam.py new file mode 100644 index 00000000..cff22b51 --- /dev/null +++ b/slixmpp/plugins/xep_0405/mix_pam.py @@ -0,0 +1,88 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2020 Mathieu Pasquet <mathieui@mathieui.net> + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" +from typing import ( + Optional, + Set, +) + +from slixmpp import JID, Iq +from slixmpp.exceptions import IqError, IqTimeout +from slixmpp.plugins import BasePlugin +from slixmpp.plugins.xep_0405 import stanza +from slixmpp.plugins.xep_0369 import stanza as mix_stanza + + +BASE_NODES = [ + 'urn:xmpp:mix:nodes:messages', + 'urn:xmpp:mix:nodes:participants', + 'urn:xmpp:mix:nodes:info', +] + + +class XEP_0405(BasePlugin): + '''XEP-0405: MIX-PAM''' + + name = 'xep_0405' + description = 'MIX-PAM' + dependencies = {'xep_0369'} + stanza = stanza + namespace = stanza.NS + + def plugin_init(self) -> None: + stanza.register_plugins() + + async def check_server_capability(self) -> bool: + """Check if the server is MIX-PAM capable""" + result = await self.xmpp.plugin['xep_0030'].get_info(jid=self.xmpp.boundjid.bare) + features = result['disco_info']['features'] + return stanza.NS in features + + async def join_channel(self, room: JID, nick: str, subscribe: Optional[Set[str]] = None, *, + ito: Optional[JID] = None, + ifrom: Optional[JID] = None, + **iqkwargs) -> Set[str]: + """ + Join a MIX channel. + + :param JID room: JID of the MIX channel + :param str nick: Desired nickname on that channel + :param Set[str] subscribe: Set of nodes to subscribe to when joining. + If empty, all nodes will be subscribed by default. + + :rtype: Set[str] + :return: The nodes that failed to subscribe, if any + """ + if subscribe is None: + subscribe = set(BASE_NODES) + if ito is None: + ito = self.xmpp.boundjid.bare + iq = self.xmpp.make_iq_set(ito=ito, ifrom=ifrom) + iq['client_join']['channel'] = room + iq['client_join']['mix_join']['nick'] = nick + for node in subscribe: + sub = mix_stanza.Subscribe() + sub['node'] = node + iq['client_join']['mix_join'].append(sub) + result = await iq.send(**iqkwargs) + result_nodes = {sub['node'] for sub in result['client_join']['mix_join']} + return result_nodes.difference(subscribe) + + async def leave_channel(self, room: JID, *, + ito: Optional[JID] = None, + ifrom: Optional[JID] = None, + **iqkwargs) -> Iq: + """" + Leave a MIX channel + :param JID room: JID of the channel to leave + """ + if ito is None: + ito = self.xmpp.boundjid.bare + iq = self.xmpp.make_iq_set(ito=ito, ifrom=ifrom) + iq['client_leave']['channel'] = room + iq['client_leave'].enable('mix_leave') + return await iq.send(**iqkwargs) diff --git a/slixmpp/plugins/xep_0405/stanza.py b/slixmpp/plugins/xep_0405/stanza.py new file mode 100644 index 00000000..fe221bd6 --- /dev/null +++ b/slixmpp/plugins/xep_0405/stanza.py @@ -0,0 +1,43 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2020 Mathieu Pasquet <mathieui@mathieui.net> + This file is part of Slixmpp. + + See the file LICENSE for copying permissio +""" + +from slixmpp import JID +from slixmpp.stanza import Iq +from slixmpp.xmlstream import ( + ElementBase, + register_stanza_plugin, +) + +from slixmpp.plugins.xep_0369.stanza import ( + Join, + Leave, +) + +NS = 'urn:xmpp:mix:pam:2' + + +class ClientJoin(ElementBase): + namespace = NS + name = 'client-join' + plugin_attrib = 'client_join' + interfaces = {'channel'} + + +class ClientLeave(ElementBase): + namespace = NS + name = 'client-leave' + plugin_attrib = 'client_leave' + interfaces = {'channel'} + + +def register_plugins(): + register_stanza_plugin(Iq, ClientJoin) + register_stanza_plugin(ClientJoin, Join) + + register_stanza_plugin(Iq, ClientLeave) + register_stanza_plugin(ClientLeave, Leave) diff --git a/slixmpp/plugins/xep_0421/__init__.py b/slixmpp/plugins/xep_0421/__init__.py new file mode 100644 index 00000000..4595ffad --- /dev/null +++ b/slixmpp/plugins/xep_0421/__init__.py @@ -0,0 +1,13 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2020 "Maxime “pep” Buquet <pep@bouah.net>" + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin +from slixmpp.plugins.xep_0421.stanza import OccupantId +from slixmpp.plugins.xep_0421.occupant_id import XEP_0421 + +register_plugin(XEP_0421) diff --git a/slixmpp/plugins/xep_0421/occupant_id.py b/slixmpp/plugins/xep_0421/occupant_id.py new file mode 100644 index 00000000..4ee27a09 --- /dev/null +++ b/slixmpp/plugins/xep_0421/occupant_id.py @@ -0,0 +1,32 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2020 "Maxime “pep” Buquet <pep@bouah.net>" + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp import JID, Message, Presence +from slixmpp.plugins import BasePlugin +from slixmpp.xmlstream import register_stanza_plugin +from slixmpp.plugins.xep_0421 import stanza +from slixmpp.plugins.xep_0421.stanza import OccupantId + + +class XEP_0421(BasePlugin): + '''XEP-0421: Anonymous unique occupant identifiers for MUCs''' + + name = 'xep_0421' + description = 'Anonymous unique occupant identifiers for MUCs' + dependencies = {'xep_0030', 'xep_0045'} + stanza = stanza + namespace = stanza.NS + + def plugin_init(self) -> None: + # XXX: This should be MucMessage. Someday.. + register_stanza_plugin(Message, OccupantId) + register_stanza_plugin(Presence, OccupantId) + + async def has_feature(self, jid: JID) -> bool: + info = await self.xmpp['xep_0030'].get_info(jid) + return self.namespace in info.get_features() diff --git a/slixmpp/plugins/xep_0421/stanza.py b/slixmpp/plugins/xep_0421/stanza.py new file mode 100644 index 00000000..ab1128d6 --- /dev/null +++ b/slixmpp/plugins/xep_0421/stanza.py @@ -0,0 +1,41 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2020 "Maxime “pep” Buquet <pep@bouah.net>" + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.xmlstream import ElementBase + + +NS = 'urn:xmpp:occupant-id:0' + + +class OccupantId(ElementBase): + ''' + An Occupant-id tag. + + An <occupant-id/> tag is set by the MUC. + + This is useful in semi-anon MUCs (and MUC-PMs) as a stable identifier to + prevent the usual races with nicknames. + + Without occupant-id, getting the following messages from MUC history would + prevent a client from asserting senders are the same entity: + + <message type='groupchat' from='foo@muc/nick1' id='message1'> + <body>Some message</body> + <occupant-id xmlns='urn:xmpp:occupant-id:0' id='unique-opaque-id1'/> + </message> + <message type='groupchat' from='foo@muc/nick2' id='message2'> + <body>Some correction</body> + <occupant-id xmlns='urn:xmpp:occupant-id:0' id='unique-opaque-id1'/> + <replace xmlns='urn:xmpp:message-correct:0' id='message1'/> + </message> + ''' + + name = 'occupant-id' + plugin_attrib = 'occupant-id' + namespace = NS + interface = {'id'} diff --git a/slixmpp/plugins/protoxep_reactions/__init__.py b/slixmpp/plugins/xep_0444/__init__.py index e107bd16..dff4287c 100644 --- a/slixmpp/plugins/protoxep_reactions/__init__.py +++ b/slixmpp/plugins/xep_0444/__init__.py @@ -1,11 +1,11 @@ """ Slixmpp: The Slick XMPP Library - Copyright (C) 2019 Mathieu Pasquet + Copyright (C) 2020 Mathieu Pasquet This file is part of Slixmpp. See the file LICENSE for copying permission. """ from slixmpp.plugins.base import register_plugin -from slixmpp.plugins.protoxep_reactions.reactions import XEP_Reactions +from slixmpp.plugins.xep_0444.reactions import XEP_0444 -register_plugin(XEP_Reactions) +register_plugin(XEP_0444) diff --git a/slixmpp/plugins/protoxep_reactions/reactions.py b/slixmpp/plugins/xep_0444/reactions.py index e7af8fcb..bfd12499 100644 --- a/slixmpp/plugins/protoxep_reactions/reactions.py +++ b/slixmpp/plugins/xep_0444/reactions.py @@ -1,26 +1,28 @@ """ Slixmpp: The Slick XMPP Library - Copyright (C) 2019 Mathieu Pasquet + Copyright (C) 2020 Mathieu Pasquet This file is part of Slixmpp. See the file LICENSE for copying permission. """ from typing import Iterable +from slixmpp import JID from slixmpp.plugins import BasePlugin from slixmpp.stanza import Message from slixmpp.xmlstream import register_stanza_plugin from slixmpp.xmlstream.matcher import MatchXMLMask from slixmpp.xmlstream.handler import Callback -from slixmpp.plugins.protoxep_reactions import stanza +from slixmpp.plugins.xep_0444 import stanza -class XEP_Reactions(BasePlugin): - name = 'protoxep_reactions' - description = 'XEP-XXXX: Message Reactions' - dependencies = {'xep_0030'} +class XEP_0444(BasePlugin): + name = 'xep_0444' + description = 'XEP-0444: Message Reactions' + dependencies = {'xep_0030', 'xep_0334'} stanza = stanza + namespace = stanza.NS def plugin_init(self): self.xmpp.register_handler( @@ -30,25 +32,32 @@ class XEP_Reactions(BasePlugin): self._handle_reactions, ) ) - self.xmpp['xep_0030'].add_feature('urn:xmpp:reactions:0') register_stanza_plugin(Message, stanza.Reactions) + register_stanza_plugin(stanza.Reactions, stanza.Reaction, iterable=True) + + def session_bind(self, event): + self.xmpp['xep_0030'].add_feature(stanza.NS) def plugin_end(self): self.xmpp.remove_handler('Reaction received') - self.xmpp['xep_0030'].remove_feature('urn:xmpp:reactions:0') + self.xmpp['xep_0030'].remove_feature(stanza.NS) def _handle_reactions(self, message: Message): self.xmpp.event('reactions', message) + def send_reactions(self, to: JID, to_id: str, reactions: Iterable[str], *, store=True): + """Send reactions related to a message""" + msg = self.xmpp.make_message(mto=to) + self.set_reactions(msg, to_id, reactions) + if store: + msg.enable('store') + msg.send() + @staticmethod def set_reactions(message: Message, to_id: str, reactions: Iterable[str]): - """ - Add reactions to a Message object. - """ - reactions_stanza = stanza.Reactions() - reactions_stanza['to'] = to_id + """Add reactions to a Message object.""" + message['reactions']['id'] = to_id for reaction in reactions: reaction_stanza = stanza.Reaction() reaction_stanza['value'] = reaction - reactions_stanza.append(reaction_stanza) - message.append(reactions_stanza) + message['reactions'].append(reaction_stanza) diff --git a/slixmpp/plugins/xep_0444/stanza.py b/slixmpp/plugins/xep_0444/stanza.py new file mode 100644 index 00000000..338a244e --- /dev/null +++ b/slixmpp/plugins/xep_0444/stanza.py @@ -0,0 +1,60 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2020 Mathieu Pasquet + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from typing import Set, Iterable +from slixmpp.xmlstream import ElementBase +try: + from emoji import UNICODE_EMOJI +except ImportError: + UNICODE_EMOJI = None + + +NS = 'urn:xmpp:reactions:0' + +class Reactions(ElementBase): + name = 'reactions' + plugin_attrib = 'reactions' + namespace = NS + interfaces = {'id', 'values'} + + def get_values(self, *, all_chars=False) -> Set[str]: + """"Get all reactions as str""" + reactions = set() + for reaction in self: + value = reaction['value'] + if UNICODE_EMOJI and not all_chars: + if value in UNICODE_EMOJI: + reactions.add(reaction['value']) + else: + reactions.add(reaction['value']) + return reactions + + def set_values(self, values: Iterable[str], *, all_chars=False): + """"Set all reactions as str""" + for element in self.xml.findall('reaction'): + self.xml.remove(element) + for reaction_txt in values: + reaction = Reaction() + reaction.set_value(reaction_txt, all_chars=all_chars) + self.append(reaction) + + +class Reaction(ElementBase): + name = 'reaction' + namespace = NS + interfaces = {'value'} + + def get_value(self) -> str: + return self.xml.text + + def set_value(self, value: str, *, all_chars=False): + if UNICODE_EMOJI and not all_chars: + if not value in UNICODE_EMOJI: + raise ValueError("%s is not a valid emoji" % value) + self.xml.text = value + diff --git a/slixmpp/xmlstream/stanzabase.py b/slixmpp/xmlstream/stanzabase.py index f45e4b96..925f2abc 100644 --- a/slixmpp/xmlstream/stanzabase.py +++ b/slixmpp/xmlstream/stanzabase.py @@ -745,6 +745,8 @@ class ElementBase(object): getattr(self, set_method)(value, **kwargs) else: if attrib in self.sub_interfaces: + if isinstance(value, JID): + value = str(value) if lang == '*': return self._set_all_sub_text(attrib, value, @@ -863,6 +865,8 @@ class ElementBase(object): if value is None or value == '': self.__delitem__(name) else: + if isinstance(value, JID): + value = str(value) self.xml.attrib[name] = value def _del_attr(self, name): @@ -1497,12 +1501,7 @@ class StanzaBase(ElementBase): self.name) def send(self): - """Queue the stanza to be sent on the XML stream. - - :param bool now: Indicates if the queue should be skipped and the - stanza sent immediately. Useful for stream - initialization. Defaults to ``False``. - """ + """Queue the stanza to be sent on the XML stream.""" self.stream.send(self) def __copy__(self): diff --git a/slixmpp/xmlstream/xmlstream.py b/slixmpp/xmlstream/xmlstream.py index 3aac8c8e..af494903 100644 --- a/slixmpp/xmlstream/xmlstream.py +++ b/slixmpp/xmlstream/xmlstream.py @@ -228,6 +228,8 @@ class XMLStream(asyncio.BaseProtocol): self.add_event_handler('disconnected', self._remove_schedules) self.add_event_handler('session_start', self._start_keepalive) + + self._run_filters = None @property def loop(self): @@ -271,10 +273,12 @@ class XMLStream(asyncio.BaseProtocol): localhost """ - asyncio.ensure_future( - self.run_filters(), - loop=self.loop, - ) + if self._run_filters is None: + self._run_filters = asyncio.ensure_future( + self.run_filters(), + loop=self.loop, + ) + self.disconnect_reason = None self.cancel_connection_attempt() self.connect_loop_wait = 0 @@ -463,6 +467,8 @@ class XMLStream(asyncio.BaseProtocol): self.parser = None self.transport = None self.socket = None + if self._run_filters: + self._run_filters.cancel() def cancel_connection_attempt(self): """ @@ -474,6 +480,9 @@ class XMLStream(asyncio.BaseProtocol): if self._current_connection_attempt: self._current_connection_attempt.cancel() self._current_connection_attempt = None + if self._run_filters: + self._run_filters.cancel() + def disconnect(self, wait: float = 2.0, reason: Optional[str] = None, ignore_send_queue: bool = False) -> None: """Close the XML stream and wait for an acknowldgement from the server for diff --git a/tests/test_stanza_xep_0369.py b/tests/test_stanza_xep_0369.py new file mode 100644 index 00000000..8c3e2a6b --- /dev/null +++ b/tests/test_stanza_xep_0369.py @@ -0,0 +1,117 @@ +import unittest +from slixmpp import Iq, Message, JID +from slixmpp.test import SlixTest +from slixmpp.plugins.xep_0369 import stanza +from slixmpp.plugins.xep_0060 import stanza as pstanza +from slixmpp.plugins.xep_0369.mix_core import BASE_NODES + + +class TestMIXStanza(SlixTest): + + def setUp(self): + stanza.register_plugins() + + def testMIXJoin(self): + """Test that data is converted to base64""" + iq = Iq() + iq['type'] = 'set' + for node in BASE_NODES: + sub = stanza.Subscribe() + sub['node'] = node + iq['mix_join'].append(sub) + iq['mix_join']['nick'] = 'Toto' + + self.check(iq, """ + <iq type="set"> + <join xmlns='urn:xmpp:mix:core:1'> + <subscribe node='urn:xmpp:mix:nodes:messages'/> + <subscribe node='urn:xmpp:mix:nodes:participants'/> + <subscribe node='urn:xmpp:mix:nodes:info'/> + <nick>Toto</nick> + </join> + </iq> + """) + + def testMIXUpdateSub(self): + iq = Iq() + iq['type'] = 'set' + iq.enable('mix_updatesub') + sub = stanza.Subscribe() + sub['node'] = 'urn:xmpp:mix:nodes:someothernode' + iq['mix_updatesub'].append(sub) + + self.check(iq, """ + <iq type="set"> + <update-subscription xmlns='urn:xmpp:mix:core:1'> + <subscribe node='urn:xmpp:mix:nodes:someothernode'/> + </update-subscription> + </iq> + """) + + def testMIXLeave(self): + iq = Iq() + iq['type'] = 'set' + iq.enable('mix_leave') + + self.check(iq, """ + <iq type="set"> + <leave xmlns='urn:xmpp:mix:core:1'/> + </iq> + """) + + def testMIXSetNick(self): + iq = Iq() + iq['type'] = 'set' + iq['mix_setnick']['nick'] = 'A nick' + + self.check(iq, """ + <iq type="set"> + <setnick xmlns='urn:xmpp:mix:core:1'> + <nick>A nick</nick> + </setnick> + </iq> + """) + + def testMIXMessage(self): + msg = Message() + msg['type'] = 'groupchat' + msg['body'] = 'This is a message body' + msg['mix']['nick'] = 'A nick' + msg['mix']['jid'] = JID('toto@example.com') + + self.check(msg, """ + <message type="groupchat"> + <body>This is a message body</body> + <mix xmlns="urn:xmpp:mix:core:1"> + <nick>A nick</nick> + <jid>toto@example.com</jid> + </mix> + </message> + """) + + def testMIXNewParticipant(self): + msg = Message() + msg['pubsub_event']['items']['node'] = 'urn:xmpp:mix:nodes:participants' + item = pstanza.EventItem() + item['id'] = '123456' + item['mix_participant']['jid'] = JID('titi@example.com') + item['mix_participant']['nick'] = 'Titi' + msg['pubsub_event']['items'].append(item) + + self.check(msg, """ + <message> + <event xmlns='http://jabber.org/protocol/pubsub#event'> + <items node='urn:xmpp:mix:nodes:participants'> + <item id='123456'> + <participant xmlns='urn:xmpp:mix:core:1'> + <jid>titi@example.com</jid> + <nick>Titi</nick> + </participant> + </item> + </items> + </event> + </message> + """, use_values=False) + + +suite = unittest.TestLoader().loadTestsFromTestCase(TestMIXStanza) diff --git a/tests/test_stanza_xep_0405.py b/tests/test_stanza_xep_0405.py new file mode 100644 index 00000000..5d834cf1 --- /dev/null +++ b/tests/test_stanza_xep_0405.py @@ -0,0 +1,55 @@ +import unittest +from slixmpp import Iq, Message, JID +from slixmpp.test import SlixTest +from slixmpp.plugins.xep_0405 import stanza +from slixmpp.plugins.xep_0369 import stanza as mstanza +from slixmpp.plugins.xep_0405.mix_pam import BASE_NODES + + +class TestMIXPAMStanza(SlixTest): + + def setUp(self): + stanza.register_plugins() + mstanza.register_plugins() + + def testMIXPAMJoin(self): + """Test that data is converted to base64""" + iq = Iq() + iq['type'] = 'set' + iq['client_join']['channel'] = JID('mix@example.com') + for node in BASE_NODES: + sub = mstanza.Subscribe() + sub['node'] = node + iq['client_join']['mix_join'].append(sub) + iq['client_join']['mix_join']['nick'] = 'Toto' + + self.check(iq, """ + <iq type="set"> + <client-join xmlns='urn:xmpp:mix:pam:2' channel='mix@example.com'> + <join xmlns='urn:xmpp:mix:core:1'> + <subscribe node='urn:xmpp:mix:nodes:messages'/> + <subscribe node='urn:xmpp:mix:nodes:participants'/> + <subscribe node='urn:xmpp:mix:nodes:info'/> + <nick>Toto</nick> + </join> + </client-join> + </iq> + """) + + + def testMIXPAMLeave(self): + iq = Iq() + iq['type'] = 'set' + iq['client_leave']['channel'] = JID('mix@example.com') + iq['client_leave'].enable('mix_leave') + + self.check(iq, """ + <iq type="set"> + <client-leave xmlns='urn:xmpp:mix:pam:2' channel='mix@example.com'> + <leave xmlns='urn:xmpp:mix:core:1'/> + </client-leave> + </iq> + """) + + +suite = unittest.TestLoader().loadTestsFromTestCase(TestMIXPAMStanza) diff --git a/tests/test_stanza_xep_0421.py b/tests/test_stanza_xep_0421.py new file mode 100644 index 00000000..dbd7a592 --- /dev/null +++ b/tests/test_stanza_xep_0421.py @@ -0,0 +1,29 @@ +import unittest +from slixmpp import JID, Message +from slixmpp.test import SlixTest +import slixmpp.plugins.xep_0421 as xep_0421 +from slixmpp.xmlstream import register_stanza_plugin + + +class TestOccupantId(SlixTest): + + def setUp(self): + register_stanza_plugin(Message, xep_0421.stanza.OccupantId) + + def testReadOccupantId(self): + result = """ + <message type='groupchat' from='foo@muc/nick1'> + <body>Some message</body> + <occupant-id xmlns='urn:xmpp:occupant-id:0' id='unique-id1'/> + </message> + """ + + msg = self.Message() + msg['type'] = 'groupchat' + msg['from'] = JID('foo@muc/nick1') + msg['body'] = 'Some message' + msg['occupant-id']['id'] = 'unique-id1' + + self.check(msg, result) + +suite = unittest.TestLoader().loadTestsFromTestCase(TestOccupantId) diff --git a/tests/test_stanza_xep_0444.py b/tests/test_stanza_xep_0444.py new file mode 100644 index 00000000..b4d5739b --- /dev/null +++ b/tests/test_stanza_xep_0444.py @@ -0,0 +1,69 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2020 Mathieu Pasquet + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import unittest +from slixmpp import Message +from slixmpp.test import SlixTest +from slixmpp.plugins.xep_0444 import XEP_0444 +import slixmpp.plugins.xep_0444.stanza as stanza +from slixmpp.xmlstream import register_stanza_plugin + + +class TestReactions(SlixTest): + + def setUp(self): + register_stanza_plugin(Message, stanza.Reactions) + register_stanza_plugin(stanza.Reactions, stanza.Reaction) + + def testCreateReactions(self): + """Testing creating Reactions.""" + + xmlstring = """ + <message> + <reactions xmlns="urn:xmpp:reactions:0" id="abcd"> + <reaction>😃</reaction> + <reaction>🤗</reaction> + </reactions> + </message> + """ + + msg = self.Message() + msg['reactions']['id'] = 'abcd' + msg['reactions']['values'] = ['😃', '🤗'] + + self.check(msg, xmlstring, use_values=False) + + self.assertEqual({'😃', '🤗'}, msg['reactions']['values']) + + + def testCreateReactionsUnrestricted(self): + """Testing creating Reactions with the extra all_chars arg.""" + + xmlstring = """ + <message> + <reactions xmlns="urn:xmpp:reactions:0" id="abcd"> + <reaction>😃</reaction> + <reaction>🤗</reaction> + <reaction>toto</reaction> + </reactions> + </message> + """ + + msg = self.Message() + msg['reactions']['id'] = 'abcd' + msg['reactions'].set_values(['😃', '🤗', 'toto'], all_chars=True) + + self.check(msg, xmlstring, use_values=False) + + self.assertEqual({'😃', '🤗'}, msg['reactions']['values']) + self.assertEqual({'😃', '🤗', 'toto'}, msg['reactions'].get_values(all_chars=True)) + with self.assertRaises(ValueError): + msg['reactions'].set_values(['😃', '🤗', 'toto'], all_chars=False) + + +suite = unittest.TestLoader().loadTestsFromTestCase(TestReactions) |