diff options
-rw-r--r-- | .gitlab-ci.yml | 3 | ||||
-rwxr-xr-x | setup.py | 3 | ||||
-rw-r--r-- | slixmpp/plugins/__init__.py | 4 | ||||
-rw-r--r-- | slixmpp/plugins/protoxep_occupantid/__init__.py | 12 | ||||
-rw-r--r-- | slixmpp/plugins/protoxep_occupantid/occupantid.py | 23 | ||||
-rw-r--r-- | slixmpp/plugins/protoxep_occupantid/stanza.py | 16 | ||||
-rw-r--r-- | slixmpp/plugins/protoxep_reactions/stanza.py | 31 | ||||
-rw-r--r-- | slixmpp/plugins/xep_0045/muc.py | 357 | ||||
-rw-r--r-- | slixmpp/plugins/xep_0045/stanza.py | 49 | ||||
-rw-r--r-- | slixmpp/plugins/xep_0363/http_upload.py | 1 | ||||
-rw-r--r-- | slixmpp/plugins/xep_0421/occupant_id.py | 3 | ||||
-rw-r--r-- | slixmpp/plugins/xep_0421/stanza.py | 1 | ||||
-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.py | 60 | ||||
-rw-r--r-- | slixmpp/xmlstream/stanzabase.py | 7 | ||||
-rw-r--r-- | slixmpp/xmlstream/xmlstream.py | 17 | ||||
-rw-r--r-- | tests/test_stanza_xep_0444.py | 69 |
18 files changed, 430 insertions, 271 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: @@ -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..a89b10f6 100644 --- a/slixmpp/plugins/__init__.py +++ b/slixmpp/plugins/__init__.py @@ -86,6 +86,6 @@ __all__ = [ 'xep_0325', # IoT Systems Control 'xep_0332', # HTTP Over XMPP Transport 'xep_0377', # Spam reporting - 'protoxep_reactions', # https://dino.im/xeps/reactions.html - 'protoxep_occupantid', # https://dino.im/xeps/occupant-id.html + '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/muc.py b/slixmpp/plugins/xep_0045/muc.py index 364c47fd..f310c03e 100644 --- a/slixmpp/plugins/xep_0045/muc.py +++ b/slixmpp/plugins/xep_0045/muc.py @@ -9,8 +9,18 @@ from __future__ import with_statement import logging - -from slixmpp import Presence, Message +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 @@ -19,11 +29,23 @@ 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, MUCMessage +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): @@ -39,19 +61,61 @@ class XEP_0045(BasePlugin): def plugin_init(self): self.rooms = {} self.our_nicks = {} - self.xep = '0045' # 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) - 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)) + 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) @@ -112,7 +176,6 @@ class XEP_0045(BasePlugin): 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) @@ -123,143 +186,96 @@ class XEP_0045(BasePlugin): return None self.xmpp.event('groupchat_subject', msg) - def jid_in_room(self, room, jid): + 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): + 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 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): + 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) - x = ET.Element('{http://jabber.org/protocol/muc}x') + stanza = self.xmpp.make_presence( + pto="%s/%s" % (room, nick), pstatus=pstatus, + pshow=pshow, pfrom=pfrom + ) + stanza.enable('muc_join') if password: - passelement = ET.Element('{http://jabber.org/protocol/muc}password') - passelement.text = password - x.append(passelement) + stanza['muc_join']['password'] = password if maxhistory: - history = ET.Element('{http://jabber.org/protocol/muc}history') - if maxhistory == "0": - history.attrib['maxchars'] = maxhistory + if maxhistory == "0": + stanza['muc_join']['history']['maxchars'] = '0' 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) + stanza['muc_join']['history']['maxstanzas'] = str(maxhistory) + self.xmpp.send(stanza) 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') + 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: - 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): + 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 ('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): + 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 ('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=''): + 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) - 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}) + msg = self.xmpp.make_message(room, mfrom=mfrom) + msg.enable('muc') + msg['muc']['invite'] = 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) + msg['muc']['invite']['reason'] = reason self.xmpp.send(msg) - def leave_muc(self, room, nick, msg='', pfrom=None): + def leave_muc(self, room: JID, nick: str, msg='', pfrom=None): """ Leave the specified room. """ if msg: @@ -268,44 +284,77 @@ class XEP_0045(BasePlugin): 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 + + 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 - try: - result = iq.send() - except IqError: - raise ValueError - except IqTimeout: - raise ValueError + 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 + raise ValueError("Configuration form not found") 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') + 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) - iq['to'] = room - iq['from'] = ifrom - iq.send() + iq = self.xmpp.make_iq_set(query, ito=room, ifrom=ifrom) + return await iq.send(**iqkwargs) - def set_room_config(self, room, config, ifrom=''): - query = ET.Element('{http://jabber.org/protocol/muc#owner}query') + 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) - iq['to'] = room - iq['from'] = ifrom - iq.send() - - def get_joined_rooms(self): + 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): + 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]) @@ -319,19 +368,15 @@ class XEP_0045(BasePlugin): else: return None - def get_roster(self, room): + 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(cls, room, affiliation='member', ifrom=None): - if affiliation not in ('outcast', 'member', 'admin', 'owner', 'none'): + def get_users_by_affiliation(self, room: JID, affiliation='member', *, ifrom: Optional[JID] = None): + # Preserve old API + if affiliation not in AFFILIATIONS: 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() + 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 index 2db83baf..9756790b 100644 --- a/slixmpp/plugins/xep_0045/stanza.py +++ b/slixmpp/plugins/xep_0045/stanza.py @@ -147,3 +147,52 @@ class MUCMessage(MUCBase): </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_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_0421/occupant_id.py b/slixmpp/plugins/xep_0421/occupant_id.py index 116bf2d9..4ee27a09 100644 --- a/slixmpp/plugins/xep_0421/occupant_id.py +++ b/slixmpp/plugins/xep_0421/occupant_id.py @@ -6,7 +6,7 @@ See the file LICENSE for copying permission. """ -from slixmpp import JID, Message +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 @@ -25,6 +25,7 @@ class XEP_0421(BasePlugin): 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) diff --git a/slixmpp/plugins/xep_0421/stanza.py b/slixmpp/plugins/xep_0421/stanza.py index 0cb93959..ab1128d6 100644 --- a/slixmpp/plugins/xep_0421/stanza.py +++ b/slixmpp/plugins/xep_0421/stanza.py @@ -36,5 +36,6 @@ class OccupantId(ElementBase): ''' 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..7eaf78a5 100644 --- a/slixmpp/xmlstream/stanzabase.py +++ b/slixmpp/xmlstream/stanzabase.py @@ -1497,12 +1497,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_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) |