Commit 8293ab4c authored by Nicoco's avatar Nicoco Committed by Philipp Hörist
Browse files

feat: Add message reactions (XEP-0444) support

parent 6f310728
......@@ -85,6 +85,7 @@ from nbxmpp.modules.delimiter import Delimiter
from nbxmpp.modules.roster import Roster
from nbxmpp.modules.last_activity import LastActivity
from nbxmpp.modules.entity_time import EntityTime
from nbxmpp.modules.reactions import Reactions
from nbxmpp.modules.misc import unwrap_carbon
from nbxmpp.modules.misc import unwrap_mam
from nbxmpp.util import get_properties_struct
......@@ -199,6 +200,7 @@ class StanzaDispatcher(Observable):
self._modules['Roster'] = Roster(self._client)
self._modules['LastActivity'] = LastActivity(self._client)
self._modules['EntityTime'] = EntityTime(self._client)
self._modules['Reactions'] = Reactions(self._client)
for instance in self._modules.values():
for handler in instance.handlers:
......
# Copyright (C) 2022 Philipp Hörist <philipp AT hoerist.com>
#
# This file is part of nbxmpp.
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 3
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; If not, see <http://www.gnu.org/licenses/>.
import typing
from nbxmpp.namespaces import Namespace
from nbxmpp.structs import StanzaHandler, MessageProperties
from nbxmpp.structs import Reactions as ReactionStruct
from nbxmpp.modules.base import BaseModule
if typing.TYPE_CHECKING:
from nbxmpp.client import Client
from nbxmpp.protocol import Message
class Reactions(BaseModule):
def __init__(self, client: 'Client'):
BaseModule.__init__(self, client)
self._client = client
self.handlers = [
StanzaHandler(
name='message',
callback=self._process_message_reaction,
ns=Namespace.REACTIONS,
priority=15,
),
]
def _process_message_reaction(
self,
_client: 'Client',
stanza: 'Message',
properties: MessageProperties
) -> None:
reactions = stanza.getTag('reactions', namespace=Namespace.REACTIONS)
if reactions is None:
return
id_ = reactions.getAttr('id')
if not id_:
self._log.warning('Reactions without ID')
return
emojis = set()
for reaction in reactions.getTags('reaction'):
# we strip for clients that might add white spaces and/or
# new lines in the reaction content.
emoji = reaction.getData().strip()
if emoji:
emojis.add(emoji)
else:
self._log.warning('Empty reaction')
properties.reactions = ReactionStruct(id_, emojis)
......@@ -134,6 +134,7 @@ class _Namespaces:
PUBSUB_OWNER: str = 'http://jabber.org/protocol/pubsub#owner'
PUBSUB_PUBLISH_OPTIONS: str = 'http://jabber.org/protocol/pubsub#publish-options'
PUBSUB_NODE_MAX: str = 'http://jabber.org/protocol/pubsub#config-node-max'
REACTIONS: str = 'urn:xmpp:reactions:0'
RECEIPTS: str = 'urn:xmpp:receipts'
REGISTER: str = 'jabber:iq:register'
REGISTER_FEATURE: str = 'http://jabber.org/features/iq-register'
......
......@@ -21,6 +21,7 @@ sub- stanzas) handling routines
from __future__ import annotations
from typing import Any
from typing import Iterable
from typing import Union
from typing import Optional
from typing import cast
......@@ -1272,6 +1273,27 @@ class Message(Protocol):
def setHint(self, hint):
self.setTag(hint, namespace=Namespace.HINTS)
def setReactions(self, target_id: str, emojis: Iterable[str]):
reactions = self.addChild(
'reactions', namespace=Namespace.REACTIONS, attrs={"id": target_id})
for e in emojis:
reactions.addChild(
'reaction', namespace=Namespace.REACTIONS, payload=[e])
def getReactions(self) -> Optional[tuple[str, set[str]]]:
reactions = self.getTag('reactions', namespace=Namespace.REACTIONS)
if not reactions:
return None
react_to = reactions.getAttr('id')
if not react_to:
return None
tags = reactions.getTags('reaction', namespace=Namespace.REACTIONS)
# strip() in case clients surround emojis with whitespace
return react_to, {t.getData().strip() for t in tags}
class Presence(Protocol):
......
......@@ -746,6 +746,11 @@ class ChatMarker(NamedTuple):
return self.type == 'acknowledged'
class Reactions(NamedTuple):
id: str
emojis: set[str]
class CommonError:
def __init__(self, stanza):
self._stanza_name = stanza.getName()
......@@ -993,6 +998,7 @@ class MessageProperties:
xhtml: Optional[str] = None
security_label = None
chatstate = None
reactions: Optional[Reactions] = None
def is_from_us(self, bare_match: bool = True):
if self.from_ is None:
......
......@@ -415,5 +415,12 @@
<xmpp:version>0.2.1</xmpp:version>
</xmpp:SupportedXep>
</implements>
<implements>
<xmpp:SupportedXep>
<xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0444.html"/>
<xmpp:status>complete</xmpp:status>
<xmpp:version>0.1.0</xmpp:version>
</xmpp:SupportedXep>
</implements>
</Project>
</rdf:RDF>
from test.lib.util import StanzaHandlerTest
from nbxmpp.structs import Reactions as ReactionStruct
from nbxmpp.modules.reactions import Reactions
from nbxmpp.protocol import Message
class MockLog:
@staticmethod
def warning(_):
pass
class MockModule:
_log = MockLog
@staticmethod
def is_emoji(s):
return Reactions.is_emoji(s)
class ReactionsTest(StanzaHandlerTest):
def test_reaction_parsing(self):
class P:
reactions: ReactionStruct
xml = '''
<message to='romeo@capulet.net/orchard' id='96d73204-a57a-11e9-88b8-4889e7820c76' type='chat'>
<reactions id='744f6e18-a57a-11e9-a656-4889e7820c76' xmlns='urn:xmpp:reactions:0'>
<reaction>👋
</reaction>
<reaction>🐢</reaction>
</reactions>
<store xmlns='urn:xmpp:hints'/>
</message>
'''
msg = Message(node=xml)
Reactions._process_message_reaction(MockModule, self, msg, P)
self.assertEqual(P.reactions.id, '744f6e18-a57a-11e9-a656-4889e7820c76')
self.assertEqual(P.reactions.emojis, {'👋', '🐢'})
def test_no_reactions(self):
class P:
reactions: ReactionStruct = None
xml = '''
<message to='romeo@capulet.net/orchard' id='96d73204-a57a-11e9-88b8-4889e7820c76' type='chat'>
<store xmlns='urn:xmpp:hints'/>
</message>
'''
msg = Message(node=xml)
Reactions._process_message_reaction(MockModule, self, msg, P)
self.assertIsNone(P.reactions)
def test_empty_reactions(self):
class P:
reactions: ReactionStruct
xml = '''
<message to='romeo@capulet.net/orchard' id='96d73204-a57a-11e9-88b8-4889e7820c76' type='chat'>
<reactions id='744f6e18-a57a-11e9-a656-4889e7820c76' xmlns='urn:xmpp:reactions:0' />
<store xmlns='urn:xmpp:hints'/>
</message>
'''
msg = Message(node=xml)
Reactions._process_message_reaction(MockModule, self, msg, P)
self.assertEqual(len(P.reactions.emojis), 0)
def test_invalid_reactions_no_id(self):
class P:
reactions: ReactionStruct
xml = '''
<message to='romeo@capulet.net/orchard' id='96d73204-a57a-11e9-88b8-4889e7820c76' type='chat'>
<reactions xmlns='urn:xmpp:reactions:0'>
<reaction>👋</reaction>
<reaction>🐢</reaction>
</reactions>
<store xmlns='urn:xmpp:hints'/>
</message>
'''
msg = Message(node=xml)
Reactions._process_message_reaction(MockModule, self, msg, P)
self.assertFalse(hasattr(P, 'reactions'))
def test_invalid_reactions_empty_id(self):
class P:
reactions: ReactionStruct
xml = '''
<message to='romeo@capulet.net/orchard' id='96d73204-a57a-11e9-88b8-4889e7820c76' type='chat'>
<reactions id='' xmlns='urn:xmpp:reactions:0'>
<reaction>👋</reaction>
<reaction>🐢</reaction>
</reactions>
<store xmlns='urn:xmpp:hints'/>
</message>
'''
msg = Message(node=xml)
Reactions._process_message_reaction(MockModule, self, msg, P)
self.assertFalse(hasattr(P, 'reactions'))
def test_invalid_reactions_empty_emoji(self):
class P:
reactions: ReactionStruct
xml = '''
<message to='romeo@capulet.net/orchard' id='96d73204-a57a-11e9-88b8-4889e7820c76' type='chat'>
<reactions id='sadfsadf' xmlns='urn:xmpp:reactions:0'>
<reaction></reaction>
<reaction>🐢</reaction>
</reactions>
<store xmlns='urn:xmpp:hints'/>
</message>
'''
msg = Message(node=xml)
Reactions._process_message_reaction(MockModule, self, msg, P)
self.assertEqual(P.reactions.emojis, {'🐢'})
def test_set_reactions(self):
x = Message()
x.setReactions('id', '🐢')
self.assertEqual(x.getReactions(), ('id', {'🐢'}))
x = Message()
x.setReactions('id', '🐢👋')
self.assertEqual(x.getReactions(), ('id', {'🐢', '👋'}))
x = Message()
x.setReactions('id', '')
self.assertEqual(x.getReactions(), ('id', set()))
x = Message()
self.assertIsNone(x.getReactions())
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment