summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitlab-ci.yml3
-rw-r--r--docs/getting_started/muc.rst7
-rwxr-xr-xexamples/muc.py2
-rwxr-xr-xsetup.py3
-rw-r--r--slixmpp/plugins/__init__.py8
-rw-r--r--slixmpp/plugins/protoxep_occupantid/__init__.py12
-rw-r--r--slixmpp/plugins/protoxep_occupantid/occupantid.py23
-rw-r--r--slixmpp/plugins/protoxep_occupantid/stanza.py16
-rw-r--r--slixmpp/plugins/protoxep_reactions/stanza.py31
-rw-r--r--slixmpp/plugins/xep_0045.py422
-rw-r--r--slixmpp/plugins/xep_0045/__init__.py14
-rw-r--r--slixmpp/plugins/xep_0045/muc.py382
-rw-r--r--slixmpp/plugins/xep_0045/stanza.py198
-rw-r--r--slixmpp/plugins/xep_0047/stream.py4
-rw-r--r--slixmpp/plugins/xep_0115/caps.py7
-rw-r--r--slixmpp/plugins/xep_0363/http_upload.py1
-rw-r--r--slixmpp/plugins/xep_0369/__init__.py13
-rw-r--r--slixmpp/plugins/xep_0369/mix_core.py288
-rw-r--r--slixmpp/plugins/xep_0369/stanza.py121
-rw-r--r--slixmpp/plugins/xep_0403/__init__.py13
-rw-r--r--slixmpp/plugins/xep_0403/mix_presence.py47
-rw-r--r--slixmpp/plugins/xep_0403/stanza.py37
-rw-r--r--slixmpp/plugins/xep_0404/__init__.py13
-rw-r--r--slixmpp/plugins/xep_0404/mix_anon.py101
-rw-r--r--slixmpp/plugins/xep_0404/stanza.py43
-rw-r--r--slixmpp/plugins/xep_0405/__init__.py13
-rw-r--r--slixmpp/plugins/xep_0405/mix_pam.py88
-rw-r--r--slixmpp/plugins/xep_0405/stanza.py43
-rw-r--r--slixmpp/plugins/xep_0421/__init__.py13
-rw-r--r--slixmpp/plugins/xep_0421/occupant_id.py32
-rw-r--r--slixmpp/plugins/xep_0421/stanza.py41
-rw-r--r--slixmpp/plugins/xep_0444/__init__.py (renamed from slixmpp/plugins/protoxep_reactions/__init__.py)6
-rw-r--r--slixmpp/plugins/xep_0444/reactions.py (renamed from slixmpp/plugins/protoxep_reactions/reactions.py)39
-rw-r--r--slixmpp/plugins/xep_0444/stanza.py60
-rw-r--r--slixmpp/xmlstream/stanzabase.py11
-rw-r--r--slixmpp/xmlstream/xmlstream.py17
-rw-r--r--tests/test_stanza_xep_0369.py117
-rw-r--r--tests/test_stanza_xep_0405.py55
-rw-r--r--tests/test_stanza_xep_0421.py29
-rw-r--r--tests/test_stanza_xep_0444.py69
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):
"""
diff --git a/setup.py b/setup.py
index 5b122388..9ba0b5df 100755
--- a/setup.py
+++ b/setup.py
@@ -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)