From ce8c5840644dfe1a3d96616aeba5abe65395cba2 Mon Sep 17 00:00:00 2001 From: Jonas Wielicki Date: Fri, 10 Mar 2017 16:34:47 +0100 Subject: [PATCH 01/40] Re-write tracking module --- aioxmpp/tracking.py | 232 ++++++++++++----- docs/api/changelog.rst | 4 + tests/test_tracking.py | 571 +++++++++++++++-------------------------- 3 files changed, 375 insertions(+), 432 deletions(-) diff --git a/aioxmpp/tracking.py b/aioxmpp/tracking.py index b2330865..79027f09 100644 --- a/aioxmpp/tracking.py +++ b/aioxmpp/tracking.py @@ -30,6 +30,10 @@ This module was added in version 0.5. +.. versionchanged:: 0.9 + + This module was completely rewritten in 0.9. + .. seealso:: Method :meth:`~.muc.Room.send_tracked_message` @@ -63,31 +67,19 @@ class MessageState(Enum): This is a final state. - .. attribute:: TIMED_OUT - - The tracking has timed out. Whether a timeout exists and how it is - handled depends on the tracking implementation. - - This is a final state. - - .. attribute:: CLOSED - - The tracking itself got aborted and cannot make a statement about the - delivery of the stanza. - - This is a final state. - .. attribute:: ERROR An error reply stanza has been received for the stanza which was sent. - This is a final state. + This is, in most cases, a final state. .. attribute:: IN_TRANSIT The message is still queued for sending or has been sent to the peer server without stream management. + Depending on the tracking implementation, this may be a final state. + .. attribute:: DELIVERED_TO_SERVER The message has been delivered to the server and the server acked the @@ -97,8 +89,9 @@ class MessageState(Enum): .. attribute:: DELIVERED_TO_RECIPIENT - The message has been delivered to the recipient. Depending on the - tracking implementation, this may be a final state. + The message has been delivered to the recipient. + + Depending on the tracking implementation, this may be a final state. .. attribute:: SEEN_BY_RECIPIENT @@ -107,73 +100,174 @@ class MessageState(Enum): """ - def __lt__(self, other): - if ((other == MessageState.ABORTED or - other == MessageState.CLOSED or - other == MessageState.ERROR) and - self != MessageState.IN_TRANSIT): - return True - if (other == MessageState.TIMED_OUT and - self != MessageState.IN_TRANSIT and - self != MessageState.DELIVERED_TO_SERVER): - return True - return self.value < other.value - IN_TRANSIT = 0 ABORTED = 1 - CLOSED = 2 - ERROR = 3 - DELIVERED_TO_SERVER = 4 - TIMED_OUT = 5 - DELIVERED_TO_RECIPIENT = 6 - SEEN_BY_RECIPIENT = 7 + ERROR = 2 + DELIVERED_TO_SERVER = 3 + DELIVERED_TO_RECIPIENT = 4 + SEEN_BY_RECIPIENT = 5 -class MessageTracker(aioxmpp.statemachine.OrderedStateMachine): +class MessageTracker: """ - This is the high-level equivalent of the :class:`~.StanzaToken`. This - structure is used by different tracking implementations. + This is the high-level equivalent of the :class:`~.StanzaToken`. - This is also a :class:`.OrderedStateMachine`, so see there for other - methods which allow waiting for a specific state. + This structure is used by different tracking implementations. The interface + of this class is split in two parts: - .. attribute:: state + 1. The public interface for use by applications. + 2. The "protected" interface for use by tracking implementations. - The current :class:`MessageState` of the :class:`MessageTracker`. Do - **not** write to this attribute from user code. Writing to this - attribute is intended only for the tracking implementation. + :class:`MessageTracker` objects are designed to be drivable from multiple + tracking implementations at once. The idea is that different tracking + implementations can cover different parts of the path a stanza takes: one + can cover the path to the server (by hooking into the events of a + :class:`~.StanzaToken`), the other implementation can use e.g. :xep:`184` to + determine delivery at the target and so on. - .. attribute:: token + Methods and attributes from the "protected" interface are marked by a + leading underscore. - The :class:`~.StanzaToken` of the message. This is usually set by the - tracking implementation right when the tracker is initialised. + .. autoattribute:: state - .. signal:: on_state_change(state) + .. autoattribute:: response - The signal is emitted with the new state as its only argument when the - state of the message tracker changes + .. autoattribute:: closed - """ + .. signal:: on_state_changed(new_state, response=None) + + Emits when a new state is entered. - on_state_change = aioxmpp.callbacks.Signal() + :param new_state: The new state of the tracker. + :type new_state: :class:`~.MessageState` member + :param response: A stanza related to the state. + :type response: :class:`~.StanzaBase` or :data:`None` - def __init__(self, token=None): - super().__init__(MessageState.IN_TRANSIT) - self.token = token + The is *not* emitted when the tracker is closed. - def on_stanza_state_change(self, token, stanza_state): - new_state = self.state - if stanza_state == stream.StanzaState.ABORTED: - new_state = MessageState.ABORTED - elif stanza_state == stream.StanzaState.DROPPED: - new_state = MessageState.ABORTED - elif stanza_state == stream.StanzaState.ACKED: - new_state = MessageState.DELIVERED_TO_SERVER + .. signal:: on_closed() - if new_state != self.state and not new_state < self.state: - self.state = new_state + Emits when the tracker is closed. + + .. automethod:: close + + "Protected" interface: + + .. automethod:: _set_state + + """ - @aioxmpp.statemachine.OrderedStateMachine.state.setter - def state(self, new_state): - aioxmpp.statemachine.OrderedStateMachine.state.fset(self, new_state) - self.on_state_change(new_state) + on_closed = aioxmpp.callbacks.Signal() + on_state_changed = aioxmpp.callbacks.Signal() + + def __init__(self): + super().__init__() + self._state = MessageState.IN_TRANSIT + self._response = None + self._closed = False + + @property + def state(self): + """ + The current state of the tracking. Read-only. + """ + return self._state + + @property + def response(self): + """ + A stanza which is relevant to the current state. For + :attr:`.MessageState.ERROR`, this will generally be a + :class:`.MessageType.ERROR` stanza. For other states, this is either + :data:`None` or another stanza depending on the tracking + implementation. + """ + return self._response + + @property + def closed(self): + """ + Boolean indicator whether the tracker is closed. + + .. seealso:: + + :meth:`close` for details. + """ + return self._closed + + def close(self): + """ + Close the tracking, clear all references to the tracker and release all + tracking-related resources. + + This operation is idempotent. It does not change the :attr:`state`, but + :attr:`closed` turns :data:`True`. + + The :meth:`on_closed` event is only fired on the first call to + :meth:`close`. + """ + if self._closed: + return + self._closed = True + self.on_closed() + + # "Protected" Interface + + def _set_state(self, new_state, response=None): + """ + Set the state of the tracker. + + :param new_state: The new state of the tracker. + :type new_state: :class:`~.MessageState` member + :param response: A stanza related to the new state. + :type response: :class:`~.StanzaBase` or :data:`None` + :raise ValueError: if a forbidden state transition is attempted. + :raise RuntimeError: if the tracker is closed. + + The state of the tracker is set to the `new_state`. The + :attr:`response` is also overriden with the new value, no matter if the + new or old value is :data:`None` or not. The :meth:`on_state_changed` + event is emitted. + + The following transitions are forbidden and attempting to perform them + will raise :class:`ValueError`: + + * any state -> :attr:`~.MessageState.IN_TRANSIT` + * :attr:`~.MessageState.DELIVERED_TO_RECIPIENT` -> + :attr:`~.MessageState.DELIVERED_TO_SERVER` + * :attr:`~.MessageState.SEEN_BY_RECIPIENT` -> + :attr:`~.MessageState.DELIVERED_TO_RECIPIENT` + * :attr:`~.MessageState.SEEN_BY_RECIPIENT` -> + :attr:`~.MessageState.DELIVERED_TO_SERVER` + * :attr:`~.MessageState.ABORTED` -> any state + * :attr:`~.MessageState.ERROR` -> any state + + If the tracker is already :meth:`close`\ -d, :class:`RuntimeError` is + raised. This check happens *before* a test is made whether the + transition is valid. + + This method is part of the "protected" interface. + """ + if self._closed: + raise RuntimeError("message tracker is closed") + + # reject some transitions as documented + if (self._state == MessageState.ABORTED or + self._state == MessageState.ERROR or + new_state == MessageState.IN_TRANSIT or + (self._state == MessageState.DELIVERED_TO_RECIPIENT and + new_state == MessageState.DELIVERED_TO_SERVER) or + (self._state == MessageState.SEEN_BY_RECIPIENT and + new_state == MessageState.DELIVERED_TO_SERVER) or + (self._state == MessageState.SEEN_BY_RECIPIENT and + new_state == MessageState.DELIVERED_TO_RECIPIENT)): + raise ValueError( + "message tracker transition from {} to {} not allowed".format( + self._state, + new_state + ) + ) + + self._state = new_state + self._response = response + self.on_state_changed(self._state, self._response) diff --git a/docs/api/changelog.rst b/docs/api/changelog.rst index 7cbc53d1..ca53ec4a 100644 --- a/docs/api/changelog.rst +++ b/docs/api/changelog.rst @@ -95,6 +95,10 @@ Version 0.9 * :func:`aioxmpp.service.depfilter` +* **Breaking change:** Re-write of :mod:`aioxmpp.tracking`. Sorry. But the new + API is more clearly defined and more correct. The (ab-)use of + :class:`aioxmpp.statemachine.OrderedStateMachine` never really worked anyways. + .. _api-changelog-0.8: Version 0.8 diff --git a/tests/test_tracking.py b/tests/test_tracking.py index fe3fb9e9..4208ab5e 100644 --- a/tests/test_tracking.py +++ b/tests/test_tracking.py @@ -19,396 +19,241 @@ # . # ######################################################################## +import itertools import unittest import unittest.mock -from enum import Enum -import aioxmpp.stanza -import aioxmpp.structs as structs -import aioxmpp.statemachine -import aioxmpp.stream as stream import aioxmpp.tracking as tracking -class TestMessageState(unittest.TestCase): - def test_is_enum(self): - self.assertTrue(issubclass( - tracking.MessageState, - Enum - )) - - def test_order(self): - self.assertLess( - tracking.MessageState.IN_TRANSIT, - tracking.MessageState.ABORTED - ) - - self.assertLess( - tracking.MessageState.ABORTED, - tracking.MessageState.DELIVERED_TO_SERVER, - ) - self.assertLess( - tracking.MessageState.ABORTED, - tracking.MessageState.DELIVERED_TO_RECIPIENT, - ) - self.assertLess( - tracking.MessageState.ABORTED, - tracking.MessageState.SEEN_BY_RECIPIENT, - ) - - self.assertLess( - tracking.MessageState.DELIVERED_TO_SERVER, - tracking.MessageState.ABORTED, - ) - self.assertLess( - tracking.MessageState.DELIVERED_TO_RECIPIENT, - tracking.MessageState.ABORTED, - ) - self.assertLess( - tracking.MessageState.SEEN_BY_RECIPIENT, - tracking.MessageState.ABORTED, - ) - - self.assertLess( - tracking.MessageState.TIMED_OUT, - tracking.MessageState.DELIVERED_TO_RECIPIENT, - ) - self.assertLess( - tracking.MessageState.TIMED_OUT, - tracking.MessageState.SEEN_BY_RECIPIENT, - ) - - self.assertLess( - tracking.MessageState.DELIVERED_TO_SERVER, - tracking.MessageState.TIMED_OUT, - ) - self.assertLess( - tracking.MessageState.DELIVERED_TO_RECIPIENT, - tracking.MessageState.TIMED_OUT, - ) - self.assertLess( - tracking.MessageState.SEEN_BY_RECIPIENT, - tracking.MessageState.TIMED_OUT, - ) - - self.assertLess( - tracking.MessageState.CLOSED, - tracking.MessageState.DELIVERED_TO_SERVER, - ) - self.assertLess( - tracking.MessageState.CLOSED, - tracking.MessageState.DELIVERED_TO_RECIPIENT, - ) - self.assertLess( - tracking.MessageState.CLOSED, - tracking.MessageState.SEEN_BY_RECIPIENT, - ) - - self.assertLess( - tracking.MessageState.DELIVERED_TO_SERVER, - tracking.MessageState.CLOSED, - ) - self.assertLess( - tracking.MessageState.DELIVERED_TO_RECIPIENT, - tracking.MessageState.CLOSED, - ) - self.assertLess( - tracking.MessageState.SEEN_BY_RECIPIENT, - tracking.MessageState.CLOSED, - ) - - self.assertLess( - tracking.MessageState.ERROR, - tracking.MessageState.DELIVERED_TO_SERVER, - ) - self.assertLess( - tracking.MessageState.ERROR, - tracking.MessageState.DELIVERED_TO_RECIPIENT, - ) - self.assertLess( - tracking.MessageState.ERROR, - tracking.MessageState.SEEN_BY_RECIPIENT, - ) - - self.assertLess( - tracking.MessageState.DELIVERED_TO_SERVER, - tracking.MessageState.ERROR, - ) - self.assertLess( - tracking.MessageState.DELIVERED_TO_RECIPIENT, - tracking.MessageState.ERROR, - ) - self.assertLess( - tracking.MessageState.SEEN_BY_RECIPIENT, - tracking.MessageState.ERROR, - ) - - self.assertLess( - tracking.MessageState.TIMED_OUT, - tracking.MessageState.ABORTED, - ) - self.assertLess( - tracking.MessageState.TIMED_OUT, - tracking.MessageState.CLOSED, - ) - self.assertLess( - tracking.MessageState.TIMED_OUT, - tracking.MessageState.ERROR, - ) - - self.assertLess( - tracking.MessageState.ABORTED, - tracking.MessageState.TIMED_OUT, - ) - self.assertLess( - tracking.MessageState.ABORTED, - tracking.MessageState.CLOSED, - ) - self.assertLess( - tracking.MessageState.ABORTED, - tracking.MessageState.ERROR, - ) - - self.assertLess( - tracking.MessageState.CLOSED, - tracking.MessageState.ABORTED, - ) - self.assertLess( - tracking.MessageState.CLOSED, - tracking.MessageState.TIMED_OUT, - ) - self.assertLess( - tracking.MessageState.CLOSED, - tracking.MessageState.ERROR, - ) - - self.assertFalse( - tracking.MessageState.ABORTED < - tracking.MessageState.IN_TRANSIT - ) - - self.assertFalse( - tracking.MessageState.ERROR < - tracking.MessageState.IN_TRANSIT - ) - - self.assertFalse( - tracking.MessageState.TIMED_OUT < - tracking.MessageState.IN_TRANSIT - ) - - self.assertLess( - tracking.MessageState.IN_TRANSIT, - tracking.MessageState.DELIVERED_TO_SERVER, - ) - self.assertLess( - tracking.MessageState.DELIVERED_TO_SERVER, - tracking.MessageState.DELIVERED_TO_RECIPIENT - ) - self.assertLess( - tracking.MessageState.DELIVERED_TO_RECIPIENT, - tracking.MessageState.SEEN_BY_RECIPIENT - ) - - class TestMessageTracker(unittest.TestCase): - def test_is_statemachine(self): - self.assertTrue(issubclass( - tracking.MessageTracker, - aioxmpp.statemachine.OrderedStateMachine - )) - def setUp(self): - self.stanza = aioxmpp.stanza.Message( - type_=structs.MessageType.CHAT - ) - self.token = stream.StanzaToken(self.stanza) - self.tr = tracking.MessageTracker(self.token) - - def test_init(self): - self.assertIs(self.tr.token, self.token) - - def test_setting_state_emits_on_state_change(self): - mock = unittest.mock.Mock() - mock.return_value = None - self.tr.on_state_change.connect(mock) - - self.tr.state = tracking.MessageState.DELIVERED_TO_SERVER - self.assertEqual( - self.tr.state, - tracking.MessageState.DELIVERED_TO_SERVER) - mock.assert_called_with( - tracking.MessageState.DELIVERED_TO_SERVER - ) - - self.tr.state = tracking.MessageState.DELIVERED_TO_RECIPIENT - self.assertEqual( - self.tr.state, - tracking.MessageState.DELIVERED_TO_RECIPIENT) - mock.assert_called_with( - tracking.MessageState.DELIVERED_TO_RECIPIENT - ) + self.t = tracking.MessageTracker() + self.listener = unittest.mock.Mock() + for ev in ["on_closed", "on_state_changed"]: + cb = getattr(self.listener, ev) + cb.return_value = None + getattr(self.t, ev).connect(cb) - self.tr.state = tracking.MessageState.SEEN_BY_RECIPIENT - self.assertEqual( - self.tr.state, - tracking.MessageState.SEEN_BY_RECIPIENT) - mock.assert_called_with( - tracking.MessageState.SEEN_BY_RECIPIENT - ) - - def test_aborting_after_delivered_to_server_not_possible(self): - mock = unittest.mock.Mock() - mock.return_value = None - self.tr.on_state_change.connect(mock) - - self.tr.state = tracking.MessageState.DELIVERED_TO_SERVER - self.assertEqual( - self.tr.state, - tracking.MessageState.DELIVERED_TO_SERVER) - - with self.assertRaises(ValueError): - self.tr.state = tracking.MessageState.ABORTED - - self.tr.state = tracking.MessageState.DELIVERED_TO_RECIPIENT - self.assertEqual( - self.tr.state, - tracking.MessageState.DELIVERED_TO_RECIPIENT) - - with self.assertRaises(ValueError): - self.tr.state = tracking.MessageState.ABORTED + def tearDown(self): + del self.t - self.tr.state = tracking.MessageState.SEEN_BY_RECIPIENT + def test_init(self): + self.assertIsNone(self.t.response) self.assertEqual( - self.tr.state, - tracking.MessageState.SEEN_BY_RECIPIENT) - - with self.assertRaises(ValueError): - self.tr.state = tracking.MessageState.ABORTED - - self.assertSequenceEqual( - mock.mock_calls, - [ - unittest.mock.call(tracking.MessageState.DELIVERED_TO_SERVER), - unittest.mock.call( - tracking.MessageState.DELIVERED_TO_RECIPIENT), - unittest.mock.call(tracking.MessageState.SEEN_BY_RECIPIENT), - ] + self.t.state, + tracking.MessageState.IN_TRANSIT ) + self.assertIs(self.t.closed, False) - def test_timeout_is_possible_after_delivered_to_server(self): - mock = unittest.mock.Mock() - mock.return_value = None - self.tr.on_state_change.connect(mock) - - self.tr.state = tracking.MessageState.DELIVERED_TO_SERVER - self.assertEqual( - self.tr.state, - tracking.MessageState.DELIVERED_TO_SERVER) - - self.tr.state = tracking.MessageState.TIMED_OUT - self.assertEqual( - self.tr.state, - tracking.MessageState.TIMED_OUT) - - def test_timeout_is_not_possible_after_delivered_to_recipient(self): - mock = unittest.mock.Mock() - mock.return_value = None - self.tr.on_state_change.connect(mock) - - self.tr.state = tracking.MessageState.DELIVERED_TO_RECIPIENT - self.assertEqual( - self.tr.state, - tracking.MessageState.DELIVERED_TO_RECIPIENT) - - with self.assertRaises(ValueError): - self.tr.state = tracking.MessageState.TIMED_OUT - - self.tr.state = tracking.MessageState.SEEN_BY_RECIPIENT - self.assertEqual( - self.tr.state, - tracking.MessageState.SEEN_BY_RECIPIENT) - - with self.assertRaises(ValueError): - self.tr.state = tracking.MessageState.TIMED_OUT + def test_state_is_not_writable(self): + with self.assertRaises(AttributeError): + self.t.state = self.t.state - def test_delivery_is_not_possible_after_timeout(self): - mock = unittest.mock.Mock() - mock.return_value = None - self.tr.on_state_change.connect(mock) + def test_response_is_not_writable(self): + with self.assertRaises(AttributeError): + self.t.response = self.t.response - self.tr.state = tracking.MessageState.TIMED_OUT - self.assertEqual( - self.tr.state, - tracking.MessageState.TIMED_OUT) - - with self.assertRaises(ValueError): - self.tr.state = tracking.MessageState.DELIVERED_TO_RECIPIENT - - with self.assertRaises(ValueError): - self.tr.state = tracking.MessageState.SEEN_BY_RECIPIENT - - def test_delivered_to_server_after_aborting_not_possible(self): - self.tr.state = tracking.MessageState.ABORTED - self.assertEqual( - self.tr.state, - tracking.MessageState.ABORTED) + def test_closed_is_not_writable(self): + with self.assertRaises(AttributeError): + self.t.closed = self.t.closed - with self.assertRaises(ValueError): - self.tr.state = tracking.MessageState.DELIVERED_TO_SERVER + def test_close_sets_closed_to_true(self): + self.t.close() + self.assertIs(self.t.closed, True) - with self.assertRaises(ValueError): - self.tr.state = tracking.MessageState.IN_TRANSIT + def test_close_fires_event(self): + self.t.close() + self.listener.on_closed.assert_called_once_with() + self.listener.on_state_changed.assert_not_called() - with self.assertRaises(ValueError): - self.tr.state = tracking.MessageState.DELIVERED_TO_RECIPIENT + def test_close_is_idempotent(self): + self.t.close() + self.listener.on_closed.assert_called_once_with() + self.listener.on_state_changed.assert_not_called() + self.t.close() + self.listener.on_closed.assert_called_once_with() + self.listener.on_state_changed.assert_not_called() - with self.assertRaises(ValueError): - self.tr.state = tracking.MessageState.SEEN_BY_RECIPIENT - - def test_on_stanza_state_change_to_aborted(self): - self.tr.on_stanza_state_change(self.token, stream.StanzaState.ABORTED) - self.assertEqual( - self.tr.state, - tracking.MessageState.ABORTED - ) - - def test_on_stanza_state_change_to_acked(self): - self.tr.on_stanza_state_change(self.token, stream.StanzaState.ACKED) - self.assertEqual( - self.tr.state, - tracking.MessageState.DELIVERED_TO_SERVER + def test__set_state_updates_state_and_response(self): + self.t._set_state( + tracking.MessageState.ERROR, + unittest.mock.sentinel.response, ) - def test_on_stanza_state_change_to_sent_without_sm(self): - self.tr.on_stanza_state_change(self.token, stream.StanzaState.SENT_WITHOUT_SM) self.assertEqual( - self.tr.state, - tracking.MessageState.IN_TRANSIT + self.t.state, + tracking.MessageState.ERROR, ) - - def test_on_stanza_state_change_to_sent(self): - self.tr.on_stanza_state_change(self.token, stream.StanzaState.SENT) self.assertEqual( - self.tr.state, - tracking.MessageState.IN_TRANSIT + self.t.response, + unittest.mock.sentinel.response, ) - def test_on_stanza_state_change_to_dropped(self): - self.tr.on_stanza_state_change(self.token, stream.StanzaState.DROPPED) - self.assertEqual( - self.tr.state, - tracking.MessageState.ABORTED + def test__set_state_fires_event(self): + self.t._set_state( + tracking.MessageState.DELIVERED_TO_RECIPIENT, + unittest.mock.sentinel.response, ) - - def test_on_stanza_state_change_does_not_fail_on_state_errors(self): - self.tr.state = tracking.MessageState.DELIVERED_TO_SERVER - self.tr.on_stanza_state_change(self.token, stream.StanzaState.ABORTED) - self.assertEqual( - self.tr.state, - tracking.MessageState.DELIVERED_TO_SERVER + self.listener.on_state_changed.assert_called_once_with( + tracking.MessageState.DELIVERED_TO_RECIPIENT, + unittest.mock.sentinel.response, ) - def tearDown(self): - del self.tr + def test__set_state_rejects_transitions_from_aborted(self): + self.t._set_state( + tracking.MessageState.ABORTED, + unittest.mock.sentinel.response, + ) + self.listener.on_state_changed.reset_mock() + self.listener.on_state_changed.return_value = None + + for state in tracking.MessageState: + with self.assertRaisesRegex( + ValueError, + "transition from .* to .*not allowed"): + self.t._set_state(state) + self.listener.on_state_changed.assert_not_called() + self.assertEqual( + self.t.state, + tracking.MessageState.ABORTED, + ) + self.assertEqual( + self.t.response, + unittest.mock.sentinel.response, + ) + + def test__set_state_rejects_transitions_from_error(self): + self.t._set_state( + tracking.MessageState.ERROR, + unittest.mock.sentinel.response, + ) + self.listener.on_state_changed.reset_mock() + self.listener.on_state_changed.return_value = None + + for state in tracking.MessageState: + with self.assertRaisesRegex( + ValueError, + "transition from .* to .*not allowed"): + self.t._set_state(state) + self.listener.on_state_changed.assert_not_called() + self.assertEqual( + self.t.state, + tracking.MessageState.ERROR, + ) + self.assertEqual( + self.t.response, + unittest.mock.sentinel.response, + ) + + def test__set_state_rejects_transitions_to_in_transit(self): + for state in tracking.MessageState: + t = tracking.MessageTracker() + if state != t.state: + t._set_state(state, unittest.mock.sentinel.response) + on_state_changed = unittest.mock.Mock() + t.on_state_changed.connect(on_state_changed) + with self.assertRaisesRegex( + ValueError, + "transition from .* to .*not allowed"): + t._set_state(tracking.MessageState.IN_TRANSIT) + on_state_changed.assert_not_called() + self.assertEqual( + t.state, + state, + ) + if state != t.state: + self.assertEqual( + t.response, + unittest.mock.sentinel.response, + ) + + def test__set_state_rejects_some_other_transitions(self): + to_reject = [ + ( + tracking.MessageState.DELIVERED_TO_RECIPIENT, + tracking.MessageState.DELIVERED_TO_SERVER, + ), + ( + tracking.MessageState.SEEN_BY_RECIPIENT, + tracking.MessageState.DELIVERED_TO_SERVER, + ), + ( + tracking.MessageState.SEEN_BY_RECIPIENT, + tracking.MessageState.DELIVERED_TO_RECIPIENT, + ), + ] + + for state1, state2 in itertools.product( + tracking.MessageState, + tracking.MessageState): + if state1 == tracking.MessageState.ABORTED: + # already tested elsewhere + continue + if state1 == tracking.MessageState.ERROR: + # already tested elsewhere + continue + if state2 == tracking.MessageState.IN_TRANSIT: + # already tested elsewhere + continue + t = tracking.MessageTracker() + + if state1 != t.state: + t._set_state(state1, unittest.mock.sentinel.response) + + on_state_changed = unittest.mock.Mock() + t.on_state_changed.connect(on_state_changed) + + if (state1, state2) in to_reject: + with self.assertRaisesRegex( + ValueError, + "transition from .* to .*not allowed", + msg=(state1, state2)): + t._set_state(state2) + on_state_changed.assert_not_called() + self.assertEqual( + t.state, + state1, + ) + if state1 != t.state: + self.assertEqual( + t.response, + unittest.mock.sentinel.response, + ) + + else: + t._set_state( + state2, + unittest.mock.sentinel.response2 + ) + self.assertEqual( + t.state, + state2, + ) + self.assertEqual( + t.response, + unittest.mock.sentinel.response2, + ) + + def test__set_state_bails_out_early_if_closed(self): + self.t._set_state( + tracking.MessageState.DELIVERED_TO_SERVER, + unittest.mock.sentinel.response, + ) + + self.t.close() + + for s in tracking.MessageState: + with self.assertRaisesRegex( + RuntimeError, + "tracker is closed"): + self.t._set_state(s) + self.assertEqual( + self.t.state, + tracking.MessageState.DELIVERED_TO_SERVER, + ) + self.assertEqual( + self.t.response, + unittest.mock.sentinel.response, + ) From f9b0311ac1f54b4654f63b9a83607c5ea69a28cf Mon Sep 17 00:00:00 2001 From: Jonas Wielicki Date: Sat, 11 Mar 2017 09:35:11 +0100 Subject: [PATCH 02/40] Add set_timeout to MessageTracker and general remarks about timeouts --- aioxmpp/tracking.py | 61 ++++++++++++++++++++++++++++++++++++++++-- tests/test_tracking.py | 30 +++++++++++++++++++++ 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/aioxmpp/tracking.py b/aioxmpp/tracking.py index 79027f09..255949bb 100644 --- a/aioxmpp/tracking.py +++ b/aioxmpp/tracking.py @@ -39,6 +39,39 @@ Method :meth:`~.muc.Room.send_tracked_message` implements tracking for messages sent through a MUC. +.. _api-tracking-memory: + +General Remarks about Tracking and Memory Consumption +===================================================== + + +Tracking stanzas costs memory. There are basically two options on how to +implement the management of additional information: + +1. Either the tracking stops when the :class:`MessageTracker` is released (i.e. + the last reference to it gets collected). + +2. Or the tracking is stopped explicitly by the user. + +Option (1) has the appeal that users (applications) do not have to worry about +properly releasing the tracking objects. However, it has the downside that +applications have to keep the :class:`MessageeTracker` instance around. Remember +that connecting to callbacks of an object is *not* enough to keep it alive. + +Option (2) is somewhat like file objects work: in theory, you have to close +them explicitly and manually: if you do not, there is no guarantee when the +file is actually closed. It is thus a somewhat known Python idiom, and also is +more explicit. And it doesn’t break callbacks. + +The implementation of :class:`MessageTracker` uses **Option 2**. So you have to +:meth:`MessageTracker.close` all :class:`MessageTracker` objects to ensure that +all tracking resources associated with it are released; this stops any tracking +which is still in progress. + +It is strongly recommended that you close message trackers after a timeout. You +can use :meth:`MessageTracker.set_timeout` for that, or manually call +:meth:`MessageTracker.close` as desired. + Interfaces ========== @@ -47,11 +80,12 @@ .. autoclass:: MessageState """ +import asyncio + +from datetime import timedelta from enum import Enum import aioxmpp.callbacks -import aioxmpp.statemachine -import aioxmpp.stream as stream class MessageState(Enum): @@ -151,6 +185,8 @@ class MessageTracker: .. automethod:: close + .. automethod:: set_timeout + "Protected" interface: .. automethod:: _set_state @@ -211,6 +247,27 @@ def close(self): self._closed = True self.on_closed() + def set_timeout(self, timeout): + """ + Automatically close the tracker after `timeout` has elapsed. + + :param timeout: The timeout after which the tracker is closed + automatically. + :type timeout: :class:`numbers.Real` or :class:`datetime.timedelta` + + If the `timeout` is not a :class:`datetime.timedelta` instance, it is + assumed to be given as seconds. + + The timeout cannot be cancelled after it has been set. It starts at the + very moment :meth:`set_timeout` is called. + """ + loop = asyncio.get_event_loop() + + if isinstance(timeout, timedelta): + timeout = timeout.total_seconds() + + loop.call_later(timeout, self.close) + # "Protected" Interface def _set_state(self, new_state, response=None): diff --git a/tests/test_tracking.py b/tests/test_tracking.py index 4208ab5e..449d040f 100644 --- a/tests/test_tracking.py +++ b/tests/test_tracking.py @@ -19,10 +19,12 @@ # . # ######################################################################## +import contextlib import itertools import unittest import unittest.mock +from datetime import timedelta import aioxmpp.tracking as tracking @@ -257,3 +259,31 @@ def test__set_state_bails_out_early_if_closed(self): self.t.response, unittest.mock.sentinel.response, ) + + def test__set_timeout_with_number_calls_call_later(self): + with contextlib.ExitStack() as stack: + get_event_loop = stack.enter_context(unittest.mock.patch( + "asyncio.get_event_loop", + )) + + self.t.set_timeout(unittest.mock.sentinel.timeout) + + get_event_loop.assert_called_once_with() + get_event_loop().call_later.assert_called_once_with( + unittest.mock.sentinel.timeout, + self.t.close, + ) + + def test__set_timeout_with_timedelta_calls_call_later(self): + with contextlib.ExitStack() as stack: + get_event_loop = stack.enter_context(unittest.mock.patch( + "asyncio.get_event_loop", + )) + + self.t.set_timeout(timedelta(days=1)) + + get_event_loop.assert_called_once_with() + get_event_loop().call_later.assert_called_once_with( + timedelta(days=1).total_seconds(), + self.t.close, + ) From f46127a5c04b2e95d623fc20d181e1352a458acc Mon Sep 17 00:00:00 2001 From: Jonas Wielicki Date: Sun, 2 Apr 2017 19:15:33 +0200 Subject: [PATCH 03/40] Implement basic Message tracking The BasicTrackingService can track error replies as well as the delivery of the stanza up to the server. --- aioxmpp/tracking.py | 143 ++++++++++++++ tests/test_tracking.py | 425 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 568 insertions(+) diff --git a/aioxmpp/tracking.py b/aioxmpp/tracking.py index 255949bb..e1dc34a4 100644 --- a/aioxmpp/tracking.py +++ b/aioxmpp/tracking.py @@ -72,6 +72,11 @@ can use :meth:`MessageTracker.set_timeout` for that, or manually call :meth:`MessageTracker.close` as desired. +Tracking implementations +======================== + +.. autoclass:: BasicTrackingService + Interfaces ========== @@ -81,11 +86,13 @@ """ import asyncio +import functools from datetime import timedelta from enum import Enum import aioxmpp.callbacks +import aioxmpp.service class MessageState(Enum): @@ -328,3 +335,139 @@ def _set_state(self, new_state, response=None): self._state = new_state self._response = response self.on_state_changed(self._state, self._response) + + +class BasicTrackingService(aioxmpp.service.Service): + """ + Error handling and :class:`~.StanzaToken`\ -based tracking for messages. + + This service provides the most basic tracking of message stanzas. It can be + combined with other forms of tracking. + + Specifically, the stanza is tracked using the means of + :class:`~.StanzaToken`, that is, until it is acknowledged by the server. In + addition, error stanzas in reply to the message are also tracked (but they + do not override states occuring after + :attr:`~.MessageState.DELIVERED_TO_SERVER`). + + Tracking stanzas: + + .. automethod:: send_tracked + + .. automethod:: attach_tracker + """ + + def __init__(self, client, **kwargs): + super().__init__(client, **kwargs) + self._trackers = {} + + @aioxmpp.service.inbound_message_filter + def _inbound_message_filter(self, message): + try: + if message.type_ != aioxmpp.MessageType.ERROR: + return message + except AttributeError: + return message + + try: + key = message.from_.bare(), message.id_ + except AttributeError: + return message + + try: + tracker = self._trackers.pop(key) + except KeyError: + return message + + if tracker.state == MessageState.DELIVERED_TO_RECIPIENT: + return + if tracker.state == MessageState.SEEN_BY_RECIPIENT: + return + tracker._set_state(MessageState.ERROR, message) + + def _tracker_closed(self, key): + self._trackers.pop(key, None) + + def _stanza_sent(self, tracker, token, fut): + try: + fut.result() + except asyncio.CancelledError: + pass + except: + tracker._set_state(MessageState.ABORTED) + else: + tracker._set_state(MessageState.DELIVERED_TO_SERVER) + + def send_tracked(self, stanza, tracker): + """ + Send a message stanza with tracking. + + :param stanza: Message stanza to send. + :type stanza: :class:`aioxmpp.Message` + :param tracker: Message tracker to use. + :type tracker: :class:`~.MessageTracker` + :rtype: :class:`~.StanzaToken` + :return: The token used to send the stanza. + + If `tracker` is :data:`None`, a new :class:`~.MessageTracker` is + created. + + This configures tracking for the stanza as if by calling + :meth:`attach_tracker` with a `token` and sends the stanza through the + stream. + + .. seealso:: + + :meth:`attach_tracker` + can be used if the stanza cannot be sent (e.g. because it is a + carbon-copy) or has already been sent. + """ + token = self.client.stream.enqueue(stanza) + self.attach_tracker(stanza, tracker, token) + return token + + def attach_tracker(self, stanza, tracker=None, token=None): + """ + Configure tracking for a stanza without sending it. + + :param stanza: Message stanza to send. + :type stanza: :class:`aioxmpp.Message` + :param tracker: Message tracker to use. + :type tracker: :class:`~.MessageTracker` or :data:`None` + :param token: Optional stanza token for more fine-grained tracking. + :type token: :class:`~.StanzaToken` + :rtype: :class:`~.MessageTracker` + :return: The message tracker. + + If `tracker` is :data:`None`, a new :class:`~.MessageTracker` is + created. + + If `token` is not :data:`None`, updates to the stanza `token` are + reflected in the `tracker`. + + If an error reply is received, the tracker will enter + :class:`~.MessageState.ERROR` and the error will be set as + :attr:`~.MessageTracker.response`. + + You should use :meth:`send_tracked` if possible. This method however is + very useful if you need to track carbon copies of sent messages, as a + stanza token is not available here and re-sending the message to obtain + one is generally not desirable ☺. + """ + if tracker is None: + tracker = MessageTracker() + stanza.autoset_id() + key = stanza.to.bare(), stanza.id_ + self._trackers[key] = tracker + tracker.on_closed.connect( + functools.partial(self._tracker_closed, key) + ) + if token is not None: + token.future.add_done_callback( + functools.partial( + self._stanza_sent, + tracker, + token, + ) + ) + return tracker diff --git a/tests/test_tracking.py b/tests/test_tracking.py index 449d040f..4a49a924 100644 --- a/tests/test_tracking.py +++ b/tests/test_tracking.py @@ -19,6 +19,7 @@ # . # ######################################################################## +import asyncio import contextlib import itertools import unittest @@ -26,8 +27,19 @@ from datetime import timedelta +import aioxmpp.service import aioxmpp.tracking as tracking +from aioxmpp.utils import namespaces + +from aioxmpp.testutils import ( + make_connected_client, +) + + +TEST_LOCAL = aioxmpp.JID.fromstr("romeo@montague.example/garden") +TEST_PEER = aioxmpp.JID.fromstr("juliet@capulet.example/chamber") + class TestMessageTracker(unittest.TestCase): def setUp(self): @@ -287,3 +299,416 @@ def test__set_timeout_with_timedelta_calls_call_later(self): timedelta(days=1).total_seconds(), self.t.close, ) + + +class TestBasicTrackingService(unittest.TestCase): + def setUp(self): + self.cc = make_connected_client() + self.s = tracking.BasicTrackingService(self.cc) + + def tearDown(self): + del self.s + del self.cc + + def test_is_service(self): + self.assertTrue(issubclass( + tracking.BasicTrackingService, + aioxmpp.service.Service, + )) + + def test_installs_message_filter(self): + self.assertTrue(aioxmpp.service.is_inbound_message_filter( + tracking.BasicTrackingService._inbound_message_filter, + )) + + def test_inbound_message_filter_forwards_stanzas(self): + for type_ in aioxmpp.MessageType: + msg = aioxmpp.Message( + type_=type_, + from_=TEST_PEER, + to=TEST_LOCAL, + ) + self.assertIs( + msg, + self.s._inbound_message_filter(msg), + ) + + def test_inbound_message_filter_forwards_broken_stanzas(self): + msg = object() + self.assertIs( + msg, + self.s._inbound_message_filter(msg), + ) + + def test_attach_tracker_calls_autoset_id(self): + msg = unittest.mock.Mock() + self.s.attach_tracker(msg) + msg.autoset_id.assert_called_once_with() + + def test_attach_tracker_with_subsequent_error_modifies_tracking(self): + tracker = tracking.MessageTracker() + msg = aioxmpp.Message( + type_=aioxmpp.MessageType.CHAT, + from_=TEST_LOCAL, + to=TEST_PEER, + ) + self.assertIs( + self.s.attach_tracker(msg, tracker), + tracker + ) + + error = msg.make_error(aioxmpp.stanza.Error.from_exception( + aioxmpp.XMPPCancelError( + (namespaces.stanzas, "feature-not-implemented") + ) + )) + + self.assertIsNone(self.s._inbound_message_filter(error)) + + self.assertEqual( + tracker.state, + tracking.MessageState.ERROR + ) + self.assertIs( + tracker.response, + error, + ) + + def test_attach_tracker_with_subsequent_error_from_bare_modifies_tracking( + self): + tracker = tracking.MessageTracker() + msg = aioxmpp.Message( + type_=aioxmpp.MessageType.CHAT, + from_=TEST_LOCAL, + to=TEST_PEER, + ) + self.assertIs( + self.s.attach_tracker(msg, tracker), + tracker + ) + + error = msg.make_error(aioxmpp.stanza.Error.from_exception( + aioxmpp.XMPPCancelError( + (namespaces.stanzas, "feature-not-implemented") + ) + )) + error.from_ = error.from_.bare() + + self.assertIsNone(self.s._inbound_message_filter(error)) + + self.assertEqual( + tracker.state, + tracking.MessageState.ERROR + ) + self.assertIs( + tracker.response, + error, + ) + + def test_attach_tracker_with_subsequent_error_from_other_id_untracked( + self): + tracker = tracking.MessageTracker() + msg = aioxmpp.Message( + type_=aioxmpp.MessageType.CHAT, + from_=TEST_LOCAL, + to=TEST_PEER, + ) + self.assertIs( + self.s.attach_tracker(msg, tracker), + tracker + ) + + error = msg.make_error(aioxmpp.stanza.Error.from_exception( + aioxmpp.XMPPCancelError( + (namespaces.stanzas, "feature-not-implemented") + ) + )) + error.id_ = "fnord" + + self.assertIs(self.s._inbound_message_filter(error), error) + + self.assertEqual( + tracker.state, + tracking.MessageState.IN_TRANSIT + ) + self.assertIs( + tracker.response, + None, + ) + + def test_attach_tracker_sets_delivered_to_server_if_ok(self): + tracker = tracking.MessageTracker() + msg = aioxmpp.Message( + type_=aioxmpp.MessageType.CHAT, + from_=TEST_LOCAL, + to=TEST_PEER, + ) + token = unittest.mock.Mock() + self.assertIs( + self.s.attach_tracker(msg, tracker, token), + tracker + ) + + token.future.add_done_callback.assert_called_once_with( + unittest.mock.ANY, + ) + + _, (cb, ), _ = token.future.add_done_callback.mock_calls[0] + + cb(token.future) + + self.assertEqual( + tracker.state, + tracking.MessageState.DELIVERED_TO_SERVER, + ) + + def test_attach_tracker_sets_aborted_if_aborted(self): + tracker = tracking.MessageTracker() + msg = aioxmpp.Message( + type_=aioxmpp.MessageType.CHAT, + from_=TEST_LOCAL, + to=TEST_PEER, + ) + token = unittest.mock.Mock() + self.assertIs( + self.s.attach_tracker(msg, tracker, token), + tracker + ) + + token.future.add_done_callback.assert_called_once_with( + unittest.mock.ANY, + ) + token.future.result.side_effect = RuntimeError() + + _, (cb, ), _ = token.future.add_done_callback.mock_calls[0] + + cb(token.future) + + self.assertEqual( + tracker.state, + tracking.MessageState.ABORTED, + ) + + def test_attach_tracker_sets_aborted_on_other_exception(self): + tracker = tracking.MessageTracker() + msg = aioxmpp.Message( + type_=aioxmpp.MessageType.CHAT, + from_=TEST_LOCAL, + to=TEST_PEER, + ) + token = unittest.mock.Mock() + self.assertIs( + self.s.attach_tracker(msg, tracker, token), + tracker + ) + + class FooException(Exception): + pass + + token.future.add_done_callback.assert_called_once_with( + unittest.mock.ANY, + ) + token.future.result.side_effect = FooException() + + _, (cb, ), _ = token.future.add_done_callback.mock_calls[0] + + cb(token.future) + + self.assertEqual( + tracker.state, + tracking.MessageState.ABORTED, + ) + + def test_attach_tracker_ignores_cancelled_stanza_token(self): + tracker = tracking.MessageTracker() + msg = aioxmpp.Message( + type_=aioxmpp.MessageType.CHAT, + from_=TEST_LOCAL, + to=TEST_PEER, + ) + token = unittest.mock.Mock() + self.assertIs( + self.s.attach_tracker(msg, tracker, token), + tracker + ) + + token.future.add_done_callback.assert_called_once_with( + unittest.mock.ANY, + ) + token.future.result.side_effect = asyncio.CancelledError() + + _, (cb, ), _ = token.future.add_done_callback.mock_calls[0] + + cb(token.future) + + self.assertEqual( + tracker.state, + tracking.MessageState.IN_TRANSIT, + ) + + def test_attach_tracker_does_not_set_error_if_in_delivered_state(self): + tracker = tracking.MessageTracker() + msg = aioxmpp.Message( + type_=aioxmpp.MessageType.CHAT, + from_=TEST_LOCAL, + to=TEST_PEER, + ) + self.assertIs( + self.s.attach_tracker(msg, tracker), + tracker + ) + + tracker._set_state(tracking.MessageState.DELIVERED_TO_RECIPIENT) + + error = msg.make_error(aioxmpp.stanza.Error.from_exception( + aioxmpp.XMPPCancelError( + (namespaces.stanzas, "feature-not-implemented") + ) + )) + + self.assertIsNone(self.s._inbound_message_filter(error)) + + self.assertEqual( + tracker.state, + tracking.MessageState.DELIVERED_TO_RECIPIENT + ) + self.assertIs( + tracker.response, + None, + ) + + def test_attach_tracker_does_not_set_error_if_in_seen_state(self): + tracker = tracking.MessageTracker() + msg = aioxmpp.Message( + type_=aioxmpp.MessageType.CHAT, + from_=TEST_LOCAL, + to=TEST_PEER, + ) + self.assertIs( + self.s.attach_tracker(msg, tracker), + tracker + ) + + tracker._set_state(tracking.MessageState.SEEN_BY_RECIPIENT) + + error = msg.make_error(aioxmpp.stanza.Error.from_exception( + aioxmpp.XMPPCancelError( + (namespaces.stanzas, "feature-not-implemented") + ) + )) + + self.assertIsNone(self.s._inbound_message_filter(error)) + + self.assertEqual( + tracker.state, + tracking.MessageState.SEEN_BY_RECIPIENT + ) + self.assertIs( + tracker.response, + None, + ) + + def test_attach_tracker_with_subsequent_chat_is_not_tracked(self): + tracker = tracking.MessageTracker() + msg = aioxmpp.Message( + type_=aioxmpp.MessageType.CHAT, + from_=TEST_LOCAL, + to=TEST_PEER, + ) + self.assertIs( + self.s.attach_tracker(msg, tracker), + tracker + ) + + reply = msg.make_reply() + reply.id_ = msg.id_ + self.assertIs( + reply, + self.s._inbound_message_filter(reply) + ) + + self.assertEqual( + tracker.state, + tracking.MessageState.IN_TRANSIT + ) + + def test_inbound_message_filter_ignores_closed_tracker(self): + tracker = tracking.MessageTracker() + msg = aioxmpp.Message( + type_=aioxmpp.MessageType.CHAT, + from_=TEST_LOCAL, + to=TEST_PEER, + ) + self.assertIs( + self.s.attach_tracker(msg, tracker), + tracker + ) + + tracker.close() + + error = msg.make_error(aioxmpp.stanza.Error.from_exception( + aioxmpp.XMPPCancelError( + (namespaces.stanzas, "feature-not-implemented") + ) + )) + + self.assertIs(error, self.s._inbound_message_filter(error)) + + self.assertEqual( + tracker.state, + tracking.MessageState.IN_TRANSIT, + ) + self.assertTrue(tracker.closed) + + def test_attach_tracker_autocreates_tracker_if_needed(self): + msg = aioxmpp.Message( + type_=aioxmpp.MessageType.CHAT, + from_=TEST_LOCAL, + to=TEST_PEER, + ) + tracker = self.s.attach_tracker(msg, None) + self.assertIsInstance(tracker, tracking.MessageTracker) + + error = msg.make_error(aioxmpp.stanza.Error.from_exception( + aioxmpp.XMPPCancelError( + (namespaces.stanzas, "feature-not-implemented") + ) + )) + + self.assertIsNone(self.s._inbound_message_filter(error)) + + self.assertEqual( + tracker.state, + tracking.MessageState.ERROR + ) + self.assertIs( + tracker.response, + error, + ) + + def test_send_tracked_attaches_and_returns_tracker(self): + tracker = unittest.mock.sentinel.tracker + msg = unittest.mock.sentinel.message + + with contextlib.ExitStack() as stack: + attach_tracker = stack.enter_context(unittest.mock.patch.object( + self.s, + "attach_tracker", + )) + + result = self.s.send_tracked(msg, tracker) + + self.cc.stream.enqueue.assert_called_once_with( + msg, + ) + + attach_tracker.assert_called_once_with( + msg, + tracker, + self.cc.stream.enqueue(), + ) + + self.assertEqual( + result, + self.cc.stream.enqueue(), + ) From 638619ee4e957b7c99c53f659a93b073a8caa897 Mon Sep 17 00:00:00 2001 From: Jonas Wielicki Date: Thu, 6 Apr 2017 14:48:56 +0200 Subject: [PATCH 04/40] Base implementation of Message Delivery Reciepts (XEP-0184) --- aioxmpp/mdr/__init__.py | 21 +++++ aioxmpp/mdr/service.py | 79 ++++++++++++++++++ aioxmpp/mdr/xso.py | 51 ++++++++++++ tests/mdr/__init__.py | 22 +++++ tests/mdr/test_service.py | 168 ++++++++++++++++++++++++++++++++++++++ tests/mdr/test_xso.py | 98 ++++++++++++++++++++++ 6 files changed, 439 insertions(+) create mode 100644 aioxmpp/mdr/__init__.py create mode 100644 aioxmpp/mdr/service.py create mode 100644 aioxmpp/mdr/xso.py create mode 100644 tests/mdr/__init__.py create mode 100644 tests/mdr/test_service.py create mode 100644 tests/mdr/test_xso.py diff --git a/aioxmpp/mdr/__init__.py b/aioxmpp/mdr/__init__.py new file mode 100644 index 00000000..09bb2625 --- /dev/null +++ b/aioxmpp/mdr/__init__.py @@ -0,0 +1,21 @@ +######################################################################## +# File name: __init__.py +# This file is part of: aioxmpp +# +# LICENSE +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this program. If not, see +# . +# +######################################################################## diff --git a/aioxmpp/mdr/service.py b/aioxmpp/mdr/service.py new file mode 100644 index 00000000..4fb3e0f4 --- /dev/null +++ b/aioxmpp/mdr/service.py @@ -0,0 +1,79 @@ +######################################################################## +# File name: service.py +# This file is part of: aioxmpp +# +# LICENSE +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this program. If not, see +# . +# +######################################################################## +import aioxmpp.disco +import aioxmpp.service +import aioxmpp.tracking + + +class DeliveryReceiptsService(aioxmpp.service.Service): + ORDER_AFTER = [aioxmpp.DiscoServer] + + disco_feature = aioxmpp.disco.register_feature("urn:xmpp:receipts") + + def __init__(self, client, **kwargs): + super().__init__(client, **kwargs) + self._bare_jid_maps = {} + + @aioxmpp.service.inbound_message_filter + def _inbound_message_filter(self, stanza): + recvd = stanza.xep0184_received + if recvd is not None: + try: + tracker = self._bare_jid_maps.pop( + (stanza.from_, recvd.message_id) + ) + except KeyError: + self.logger.debug( + "received unexpected/late/dup . dropping." + ) + else: + tracker._set_state( + aioxmpp.tracking.MessageState.DELIVERED_TO_RECIPIENT + ) + return None + + return stanza + + def attach_tracker(self, stanza, tracker): + """ + Return a new tracker or modify one to track the stanza. + + :param stanza: Stanza to track. + :type stanza: :class:`aioxmpp.Message` + :param tracker: Existing tracker to attach to. + :type tracker: :class:`.tracking.MessageTracker` + :return: The message tracker for the stanza. + :rtype: :class:`.tracking.MessageTracker` + + The `stanza` gets a :xep:`184` reciept request attached and internal + handlers are set up to update the `tracker` state once a confirmation + is received. + + .. warning:: + + See the :ref:`api-tracking-memory`. + + """ + stanza.xep0184_request_receipt = True + stanza.autoset_id() + self._bare_jid_maps[stanza.to, stanza.id_] = tracker + return tracker diff --git a/aioxmpp/mdr/xso.py b/aioxmpp/mdr/xso.py new file mode 100644 index 00000000..49df9126 --- /dev/null +++ b/aioxmpp/mdr/xso.py @@ -0,0 +1,51 @@ +######################################################################## +# File name: xso.py +# This file is part of: aioxmpp +# +# LICENSE +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this program. If not, see +# . +# +######################################################################## +import aioxmpp +import aioxmpp.xso as xso + +from aioxmpp.utils import namespaces + +namespaces.xep0184_receipts = "urn:xmpp:receipts" + + +aioxmpp.Message.xep0184_request_receipt = xso.ChildFlag( + (namespaces.xep0184_receipts, "request"), +) + + +class Received(xso.XSO): + TAG = namespaces.xep0184_receipts, "received" + + message_id = xso.Attr( + "id", + ) + + def __init__(self, message_id): + super().__init__() + self.message_id = message_id + + +aioxmpp.Message.xep0184_received = xso.Child( + [ + Received, + ] +) diff --git a/tests/mdr/__init__.py b/tests/mdr/__init__.py new file mode 100644 index 00000000..f6f8fc69 --- /dev/null +++ b/tests/mdr/__init__.py @@ -0,0 +1,22 @@ +######################################################################## +# File name: __init__.py +# This file is part of: aioxmpp +# +# LICENSE +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this program. If not, see +# . +# +######################################################################## + diff --git a/tests/mdr/test_service.py b/tests/mdr/test_service.py new file mode 100644 index 00000000..1093a8c2 --- /dev/null +++ b/tests/mdr/test_service.py @@ -0,0 +1,168 @@ +######################################################################## +# File name: test_service.py +# This file is part of: aioxmpp +# +# LICENSE +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this program. If not, see +# . +# +######################################################################## +import unittest +import unittest.mock + +import aioxmpp.disco +import aioxmpp.mdr.service as mdr_service +import aioxmpp.mdr.xso as mdr_xso +import aioxmpp.service +import aioxmpp.tracking + +from aioxmpp.testutils import ( + make_connected_client, +) + + +TEST_TO = aioxmpp.JID.fromstr("romeo@montague.example") + + +class TestDeliveryReceiptsService(unittest.TestCase): + def setUp(self): + self.cc = make_connected_client() + self.disco_svr = aioxmpp.DiscoServer(self.cc) + self.t = aioxmpp.tracking.MessageTracker() + self.s = mdr_service.DeliveryReceiptsService(self.cc, dependencies={ + aioxmpp.DiscoServer: self.disco_svr, + }) + self.msg = unittest.mock.Mock(spec=aioxmpp.Message) + self.msg.xep0184_request_receipt = False + self.msg.to = TEST_TO + self.msg.id_ = "foo" + + def tearDown(self): + del self.s + del self.cc + + def test_is_service(self): + self.assertIsInstance( + self.s, + aioxmpp.service.Service, + ) + + def test_registers_inbound_message_filter(self): + self.assertTrue( + aioxmpp.service.is_inbound_message_filter( + mdr_service.DeliveryReceiptsService._inbound_message_filter, + ) + ) + + def test_inbound_message_filter_returns_random_stanza(self): + stanza = unittest.mock.Mock(spec=aioxmpp.Message) + stanza.xep0184_received = None + self.assertIs( + self.s._inbound_message_filter(stanza), + stanza, + ) + + def test_declares_disco_feature(self): + self.assertIsInstance( + mdr_service.DeliveryReceiptsService.disco_feature, + aioxmpp.disco.register_feature, + ) + self.assertEqual( + mdr_service.DeliveryReceiptsService.disco_feature.feature, + "urn:xmpp:receipts", + ) + + def test_attach_tracker_sets_xep0184_request(self): + self.s.attach_tracker(self.msg, self.t) + self.assertTrue( + self.msg.xep0184_request_receipt, + ) + + def test_attach_tracker_autosets_id(self): + self.s.attach_tracker(self.msg, self.t) + self.msg.autoset_id.assert_called_once_with() + + def test_attach_tracker_returns_passed_tracker(self): + t = self.s.attach_tracker(self.msg, self.t) + self.assertIs(t, self.t) + + def test_set_tracker_state_to_DTR_on_ack_for_full_match(self): + self.msg.to = self.msg.to.replace(resource="foo") + self.s.attach_tracker(self.msg, self.t) + + ack = aioxmpp.Message( + type_=aioxmpp.MessageType.CHAT, + from_=TEST_TO.replace(resource="foo") + ) + ack.xep0184_received = mdr_xso.Received(self.msg.id_) + + self.assertEqual( + self.t.state, + aioxmpp.tracking.MessageState.IN_TRANSIT, + ) + self.assertIsNone( + self.s._inbound_message_filter(ack) + ) + + self.assertEqual( + self.t.state, + aioxmpp.tracking.MessageState.DELIVERED_TO_RECIPIENT, + ) + + def test_do_not_modify_tracker_state_on_id_mismatch(self): + self.msg.to = self.msg.to.replace(resource="foo") + self.s.attach_tracker(self.msg, self.t) + + ack = aioxmpp.Message( + type_=aioxmpp.MessageType.CHAT, + from_=TEST_TO.replace(resource="foo") + ) + ack.xep0184_received = mdr_xso.Received("fnord") + + self.assertEqual( + self.t.state, + aioxmpp.tracking.MessageState.IN_TRANSIT, + ) + self.assertIsNone( + self.s._inbound_message_filter(ack) + ) + + self.assertEqual( + self.t.state, + aioxmpp.tracking.MessageState.IN_TRANSIT, + ) + + def test_do_not_modify_tracker_state_on_bare_jid_mismatch(self): + self.msg.to = self.msg.to.replace(resource="foo") + self.s.attach_tracker(self.msg, self.t) + + ack = aioxmpp.Message( + type_=aioxmpp.MessageType.CHAT, + from_=self.msg.to.replace(localpart="nottheromeo") + ) + ack.xep0184_received = mdr_xso.Received(self.msg.id_) + + self.assertEqual( + self.t.state, + aioxmpp.tracking.MessageState.IN_TRANSIT, + ) + self.assertIsNone( + self.s._inbound_message_filter(ack) + ) + + self.assertEqual( + self.t.state, + aioxmpp.tracking.MessageState.IN_TRANSIT, + ) diff --git a/tests/mdr/test_xso.py b/tests/mdr/test_xso.py new file mode 100644 index 00000000..68b2f0ef --- /dev/null +++ b/tests/mdr/test_xso.py @@ -0,0 +1,98 @@ +######################################################################## +# File name: test_xso.py +# This file is part of: aioxmpp +# +# LICENSE +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this program. If not, see +# . +# +######################################################################## +import unittest + +import aioxmpp +import aioxmpp.xso as xso +import aioxmpp.mdr.xso as mdr_xso + +from aioxmpp.utils import namespaces + + +class TestNamespace(unittest.TestCase): + def test_receipts(self): + self.assertEqual( + namespaces.xep0184_receipts, + "urn:xmpp:receipts", + ) + + +class TestReceived(unittest.TestCase): + def test_is_xso(self): + self.assertTrue(issubclass( + mdr_xso.Received, + xso.XSO, + )) + + def test_tag(self): + self.assertEqual( + mdr_xso.Received.TAG, + (namespaces.xep0184_receipts, "received"), + ) + + def test_message_id(self): + self.assertIsInstance( + mdr_xso.Received.message_id, + xso.Attr, + ) + self.assertEqual( + mdr_xso.Received.message_id.tag, + (None, "id"), + ) + + def test_init_default(self): + with self.assertRaisesRegex(TypeError, "message_id"): + mdr_xso.Received() + + def test_init(self): + r = mdr_xso.Received("foobar") + self.assertEqual(r.message_id, "foobar") + + +class TestMessage(unittest.TestCase): + def test_xep0184_request_receipt(self): + self.assertIsInstance( + aioxmpp.Message.xep0184_request_receipt, + xso.ChildFlag, + ) + self.assertEqual( + aioxmpp.Message.xep0184_request_receipt.tag, + (namespaces.xep0184_receipts, "request"), + ) + + def test_xep0184_received(self): + self.assertIsInstance( + aioxmpp.Message.xep0184_received, + xso.Child, + ) + self.assertLessEqual( + aioxmpp.Message.xep0184_received._classes, + { + mdr_xso.Received, + } + ) + + def test_is_in_child_map(self): + self.assertIn( + mdr_xso.Received.TAG, + aioxmpp.Message.CHILD_MAP, + ) From da31c4fd075390a56efeefbdcf9b190766c70291 Mon Sep 17 00:00:00 2001 From: Jonas Wielicki Date: Sat, 11 Mar 2017 19:22:25 +0100 Subject: [PATCH 05/40] im: First draft of Modern IM interfaces --- aioxmpp/im/__init__.py | 67 +++++ aioxmpp/im/body.py | 45 +++ aioxmpp/im/conversation.py | 402 +++++++++++++++++++++++++++ aioxmpp/im/p2p.py | 165 +++++++++++ docs/api/public/im.rst | 1 + docs/api/public/index.rst | 1 + tests/im/__init__.py | 22 ++ tests/im/test_conversation.py | 100 +++++++ tests/im/test_p2p.py | 505 ++++++++++++++++++++++++++++++++++ 9 files changed, 1308 insertions(+) create mode 100644 aioxmpp/im/__init__.py create mode 100644 aioxmpp/im/body.py create mode 100644 aioxmpp/im/conversation.py create mode 100644 aioxmpp/im/p2p.py create mode 100644 docs/api/public/im.rst create mode 100644 tests/im/__init__.py create mode 100644 tests/im/test_conversation.py create mode 100644 tests/im/test_p2p.py diff --git a/aioxmpp/im/__init__.py b/aioxmpp/im/__init__.py new file mode 100644 index 00000000..7521e110 --- /dev/null +++ b/aioxmpp/im/__init__.py @@ -0,0 +1,67 @@ +######################################################################## +# File name: __init__.py +# This file is part of: aioxmpp +# +# LICENSE +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this program. If not, see +# . +# +######################################################################## +""" +:mod:`~aioxmpp.im` --- Instant Messaging Utilities and Services +############################################################### + +This subpackage provides tools for Instant Messaging applications based on +XMPP. The tools are meant to be useful for both user-facing as well as +automated IM applications. + +Terminology +=========== + +This is a short overview of the terminology. The full definitions can be found +in the glossary and are linked. + +:term:`Conversation` + Communication context for two or more parties. +:term:`Conversation Member` + An entity taking part in a :term:`Conversation`. + +Enumerations +============ + +.. autoclass:: ConversationState + +.. autoclass:: InviteMode + +Abstract base classes +===================== + +.. module:: aioxmpp.im.conversation + +.. currentmodule:: aioxmpp.im.conversation + +Conversations +------------- + +.. autoclass:: AbstractConversation + +.. autoclass:: AbstractConversationMember + +""" + +from .conversation import ( # NOQA + ConversationState, + InviteMode, +) diff --git a/aioxmpp/im/body.py b/aioxmpp/im/body.py new file mode 100644 index 00000000..184f5c24 --- /dev/null +++ b/aioxmpp/im/body.py @@ -0,0 +1,45 @@ +######################################################################## +# File name: body.py +# This file is part of: aioxmpp +# +# LICENSE +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this program. If not, see +# . +# +######################################################################## +import functools + +import aioxmpp.structs + + +@functools.singledispatch +def set_message_body(x, message): + raise NotImplementedError( + "type {!r} is not supported as message body".format( + type(x) + ) + ) + + +@set_message_body.register(str) +def set_message_body_str(x, message): + message.body.clear() + message.body[None] = x + + +@set_message_body.register(aioxmpp.structs.LanguageMap) +def set_message_body_langmap(x, message): + message.body.clear() + message.body.update(x) diff --git a/aioxmpp/im/conversation.py b/aioxmpp/im/conversation.py new file mode 100644 index 00000000..6b471eee --- /dev/null +++ b/aioxmpp/im/conversation.py @@ -0,0 +1,402 @@ +######################################################################## +# File name: conversation.py +# This file is part of: aioxmpp +# +# LICENSE +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this program. If not, see +# . +# +######################################################################## +import abc +import asyncio +import enum + +import aioxmpp.callbacks + + +class InviteMode(enum.Enum): + DIRECT = 0 + MEDIATED = 1 + + +# class AbstractConversationMember(metaclass=abc.ABCMeta): +# """ +# Interface for a member in a conversation. + +# The JIDs of a member can be either bare or full. Both bare and full JIDs +# can be used with the :class:`aioxmpp.PresenceClient` service to look up the +# presence information. + +# .. autoattribute:: direct_jid + +# .. autoattribute:: conversation_jid + +# .. autoattribute:: root_conversation + +# .. automethod:: get_direct_conversation +# """ + +# @abc.abstractproperty +# def direct_jid(self): +# """ +# A :class:`aioxmpp.JID` which can be used to directly communicate with +# the member. + +# May be :data:`None` if the direct JID is not known or has not been +# explicitly requested for the conversation. +# """ + +# @abc.abstractproperty +# def conversation_jid(self): +# """ +# A :class:`~aioxmpp.JID` which can be used to communicate with the +# member in the context of the conversation. +# """ + +# @abc.abstractproperty +# def root_conversation(self): +# """ +# The root conversation to which this member belongs. +# """ + +# def _not_implemented_error(self, what): +# return NotImplementedError( +# "{} not supported for this type of conversation".format(what) +# ) + +# @asyncio.coroutine +# def get_direct_conversation(self, *, prefer_direct=True): +# """ +# Create or get and return a direct conversation with this member. + +# :param prefer_direct: Control which JID is used to start the +# conversation. +# :type prefer_direct: :class:`bool` +# :raises NotImplementedError: if a direct conversation is not supported +# for the type of conversation to which the +# member belongs. +# :return: New or existing conversation with the conversation member. +# :rtype: :class:`.AbstractConversation` + +# This may not be available for all conversation implementations. If it +# is not available, :class:`NotImplementedError` is raised. +# """ +# raise self._not_implemented_error("direct conversation") + + +class ConversationState(enum.Enum): + """ + State of a conversation. + + .. note:: + + The members of this enumeration closely mirror the states of :xep:`85`, + with the addition of the internal :attr:`PENDING` state. The reason is + that :xep:`85` is a Final Standard and is state-of-the-art for + conversation states in XMPP. + + .. attribute:: PENDING + + The conversation has been created, possibly automatically, and the + application has not yet set the conversation state. + + .. attribute:: ACTIVE + + .. epigraph:: + + User accepts an initial content message, sends a content message, + gives focus to the chat session interface (perhaps after being + inactive), or is otherwise paying attention to the conversation. + + -- from :xep:`85` + + .. attribute:: INACTIVE + + .. epigraph:: + + User has not interacted with the chat session interface for an + intermediate period of time (e.g., 2 minutes). + + -- from :xep:`85` + + .. attribute:: GONE + + .. epigraph:: + + User has not interacted with the chat session interface, system, or + device for a relatively long period of time (e.g., 10 minutes). + + -- from :xep:`85` + + .. attribute:: COMPOSING + + .. epigraph:: + + User is actively interacting with a message input interface specific + to this chat session (e.g., by typing in the input area of a chat + window). + + -- from :xep:`85` + + .. attribute:: PAUSED + + .. epigraph:: + + User was composing but has not interacted with the message input + interface for a short period of time (e.g., 30 seconds). + + -- from :xep:`85` + + When any of the above states is entered, a notification is sent out to the + participants of the conversation. + """ + + PENDING = 0 + ACTIVE = 1 + INACTIVE = 2 + GONE = 3 + COMPOSING = 4 + PAUSED = 5 + + +class AbstractConversationMember(metaclass=abc.ABCMeta): + def __init__(self, + conversation_jid, + is_self): + super().__init__() + self._conversation_jid = conversation_jid + self._is_self = is_self + + @property + def direct_jid(self): + return None + + @property + def conversation_jid(self): + return self._conversation_jid + + @property + def is_self(self): + return self._is_self + + +class AbstractConversation(metaclass=abc.ABCMeta): + """ + Interface for a conversation. + + Signals: + + .. signal:: on_message_received(msg, member) + + A message has been received within the conversation. + + :param msg: Message which was received. + :type msg: :class:`aioxmpp.Message` + :param member: The member object of the sender. + :type member: :class:`.AbstractConversationMember` + + .. signal:: on_state_changed(member, new_state, msg) + + The conversation state of a member has changed. + + :param member: The member object of the member whose state changed. + :type member: :class:`.AbstractConversationMember` + :param new_state: The new conversation state of the member. + :type new_state: :class:`~.ConversationState` + :param msg: The stanza which conveyed the state change. + :type msg: :class:`aioxmpp.Message` + + This signal also fires for state changes of the local occupant. The + exact point at which this signal fires for the local occupant is + determined by the implementation. + + Properties: + + .. autoattribute:: members + + .. autoattribute:: me + + Methods: + + .. automethod:: send_message + + .. automethod:: send_message_tracked + + .. automethod:: kick + + .. automethod:: ban + + .. automethod:: invite + + .. automethod:: set_topic + + .. automethod:: leave + + Interface solely for subclasses: + + .. attribute:: _client + + The `client` as passed to the constructor. + + """ + + on_message_received = aioxmpp.callbacks.Signal() + on_state_changed = aioxmpp.callbacks.Signal() + + def __init__(self, service, parent=None, **kwargs): + super().__init__(**kwargs) + self._service = service + self._client = service.client + self.__parent = parent + + def _not_implemented_error(self, what): + return NotImplementedError( + "{} not supported for this type of conversation".format(what) + ) + + @property + def parent(self): + """ + The conversation to which this conversation belongs. Read-only. + + When the parent is closed, the sub-conversations are also closed. + """ + return self.__parent + + @abc.abstractproperty + def members(self): + """ + An iterable of members of this conversation. + """ + + @abc.abstractproperty + def me(self): + """ + The member representing the local member. + """ + + @asyncio.coroutine + def send_message(self, body): + """ + Send a message to the conversation. + + :param body: The message body. + + The default implementation simply calls :meth:`send_message_tracked` + and immediately cancels the tracking object. + + Subclasses may override this method with a more specialised + implementation. Subclasses which do not provide tracked message sending + **must** override this method to provide untracked message sending. + """ + tracker = yield from self.send_message_tracked(body) + tracker.cancel() + + @abc.abstractmethod + @asyncio.coroutine + def send_message_tracked(self, body, *, timeout=None): + """ + Send a message to the conversation with tracking. + + :param body: The message body. + :param timeout: Timeout for the tracking. + :type timeout: :class:`numbers.RealNumber`, :class:`datetime.timedelta` + or :data:`None` + :raise NotImplementedError: if tracking is not implemented + + Tracking may not be supported by all implementations, and the degree of + support varies with implementation. Please check the documentation + of the respective subclass. + + `timeout` is the number of seconds (or a :class:`datetime.timedelta` + object which defines the timespan) after which the tracking expires and + enters :attr:`.tracking.MessageState.TIMED_OUT` state if no response + has been received in the mean time. If `timeout` is set to + :data:`None`, the tracking never expires. + + .. warning:: + + Active tracking objects consume memory for storing the state. It is + advisable to either set a `timeout` or + :meth:`.tracking.MessageTracker.cancel` the tracking from the + application at some point to prevent degration of performance and + running out of memory. + + """ + + @asyncio.coroutine + def kick(self, member): + """ + Kick a member from a conversation. + """ + raise self._not_implemented_error("kicking occupants") + + @asyncio.coroutine + def ban(self, member, *, request_kick=True): + """ + Ban a member from re-joining a conversation. + + If `request_kick` is :data:`True`, it is ensured that the member is + kicked from the conversation, too. + """ + raise self._not_implemented_error("banning members") + + @asyncio.coroutine + def invite(self, jid, *, + preferred_mode=InviteMode.DIRECT, + allow_upgrade=False): + """ + Invite another entity to the conversation. + + Return the new conversation object to use. In many cases, this will + simply be the current conversation object, but in some cases (e.g. when + someone is invited to a one-on-one conversation), a new conversation + must be created and used. + + If `allow_upgrade` is false and a new conversation would be needed to + invite an entity, :class:`ValueError` is raised. + """ + raise self._not_implemented_error("inviting entities") + + @asyncio.coroutine + def set_topic(self, new_topic): + """ + Change the (possibly publicly) visible topic of the conversation. + """ + raise self._not_implemented_error("changing the topic") + + @abc.abstractmethod + @asyncio.coroutine + def leave(self): + """ + Leave the conversation. + + The base implementation calls + :meth:`.AbstractConversationService._conversation_left` and must be + called after all other preconditions for a leave have completed. + """ + self._service._conversation_left(self) + + +class AbstractConversationService(metaclass=abc.ABCMeta): + on_conversation_new = aioxmpp.callbacks.Signal() + on_conversation_left = aioxmpp.callbacks.Signal() + + @abc.abstractmethod + def _conversation_left(self, c): + """ + Called by :class:`AbstractConversation` after the conversation has been + left by the client. + """ diff --git a/aioxmpp/im/p2p.py b/aioxmpp/im/p2p.py new file mode 100644 index 00000000..f8ffea72 --- /dev/null +++ b/aioxmpp/im/p2p.py @@ -0,0 +1,165 @@ +######################################################################## +# File name: p2p.py +# This file is part of: aioxmpp +# +# LICENSE +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this program. If not, see +# . +# +######################################################################## +import asyncio + +import aioxmpp.service + +from .conversation import ( + AbstractConversationMember, + AbstractConversation, + AbstractConversationService, +) + + +class Member(AbstractConversationMember): + def __init__(self, peer_jid, is_self): + super().__init__(peer_jid, is_self) + + @property + def direct_jid(self): + return self._conversation_jid + + +class Conversation(AbstractConversation): + def __init__(self, service, peer_jid, parent=None): + super().__init__(service, parent=parent) + self.__peer_jid = peer_jid + self.__members = ( + Member(self._client.local_jid, True), + Member(peer_jid, False), + ) + self._client.stream.register_message_callback( + None, + self.__peer_jid, + self.__inbound_message, + ) + + def __inbound_message(self, msg): + self.on_message_received(msg) + + @property + def peer_jid(self): + return self.__peer_jid + + @property + def members(self): + return self.__members + + @property + def me(self): + return self.__members[0] + + @asyncio.coroutine + def send_message(self, msg): + msg.to = self.__peer_jid + yield from self._client.stream.send(msg) + + @asyncio.coroutine + def send_message_tracked(self, msg): + raise self._not_implemented_error("message tracking") + + @asyncio.coroutine + def leave(self): + self._client.stream.unregister_message_callback( + None, + self.__peer_jid, + ) + yield from super().leave() + + +class Service(AbstractConversationService, aioxmpp.service.Service): + """ + Manage one-to-one conversations. + + This service manages one-to-one conversations, including private + conversations running in the framework of a multi-user chat. In those + cases, the respective multi-user chat conversation service requests a + conversation from this service to use. + + For each bare JID, there can either be a single conversation for the bare + JID or zero or more conversations for full JIDs. Mixing conversations to + bare and full JIDs of the same bare JID is not allowed, because it is + ambiguous. + + If bare JIDs are used, the conversation is assumed to be between + + .. note:: + + This service does *not* automatically create new conversations when + messages which cannot be mapped to any conversation are incoming. This + is handled by the :class:`AutoOneToOneConversationService` service. The + reason for this is that it must have a lower priority than multi-user + chat services so that those are able to handle those messages if they + belong to a new private multi-user chat conversation. + + """ + + def __init__(self, client, **kwargs): + super().__init__(client, **kwargs) + self._conversationmap = {} + + def _make_conversation(self, peer_jid): + result = Conversation(self, peer_jid, parent=None) + self._conversationmap[peer_jid] = result + self.on_conversation_new(result) + return result + + @aioxmpp.service.inbound_message_filter + def _filter_inbound_message(self, msg): + try: + existing = self._conversationmap[msg.from_] + except KeyError: + try: + existing = self._conversationmap[msg.from_.bare()] + except KeyError: + existing = None + + if existing is None: + if ((msg.type_ == aioxmpp.MessageType.CHAT or + msg.type_ == aioxmpp.MessageType.NORMAL) and + msg.body): + self._make_conversation(msg.from_.bare()) + + return msg + + @asyncio.coroutine + def get_conversation(self, peer_jid, *, current_jid=None): + """ + Get or create a new one-to-one conversation with a peer. + + :param peer_jid: The JID of the peer to converse with. + :type peer_jid: :class:`aioxmpp.JID` + :param current_jid: The current JID to lock the conversation to (see + :rfc:`6121`). + :type current_jid: :class:`aioxmpp.JID` + + `peer_jid` must be a full or bare JID. + """ + try: + return self._conversationmap[peer_jid] + except KeyError: + pass + return self._make_conversation(peer_jid) + + def _conversation_left(self, conv): + del self._conversationmap[conv.peer_jid] + self.on_conversation_left(conv) diff --git a/docs/api/public/im.rst b/docs/api/public/im.rst new file mode 100644 index 00000000..e5f72903 --- /dev/null +++ b/docs/api/public/im.rst @@ -0,0 +1 @@ +.. automodule:: aioxmpp.im diff --git a/docs/api/public/index.rst b/docs/api/public/index.rst index ac0cbb00..06c449fb 100644 --- a/docs/api/public/index.rst +++ b/docs/api/public/index.rst @@ -33,6 +33,7 @@ functionality or provide backwards compatibility. disco entitycaps forms + im muc presence pubsub diff --git a/tests/im/__init__.py b/tests/im/__init__.py new file mode 100644 index 00000000..f6f8fc69 --- /dev/null +++ b/tests/im/__init__.py @@ -0,0 +1,22 @@ +######################################################################## +# File name: __init__.py +# This file is part of: aioxmpp +# +# LICENSE +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this program. If not, see +# . +# +######################################################################## + diff --git a/tests/im/test_conversation.py b/tests/im/test_conversation.py new file mode 100644 index 00000000..13508645 --- /dev/null +++ b/tests/im/test_conversation.py @@ -0,0 +1,100 @@ +######################################################################## +# File name: test_conversation.py +# This file is part of: aioxmpp +# +# LICENSE +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this program. If not, see +# . +# +######################################################################## +import asyncio +import unittest +import unittest.mock + +import aioxmpp.im.conversation as conv + +from aioxmpp.testutils import ( + run_coroutine, +) + + +class DummyConversation(conv.AbstractConversation): + def __init__(self, mock, *args, **kwargs): + super().__init__(*args, **kwargs) + self.__mock = mock + + @property + def members(self): + pass + + @property + def me(self): + pass + + @asyncio.coroutine + def send_message_tracked(self, *args, **kwargs): + return self.__mock.send_message_tracked(*args, **kwargs) + + @asyncio.coroutine + def leave(self): + yield from super().leave() + + +class TestConversation(unittest.TestCase): + def setUp(self): + self.cc = unittest.mock.sentinel.client + self.parent = unittest.mock.sentinel.parent + self.svc = unittest.mock.Mock(["client", "_conversation_left"]) + self.svc.client = self.cc + self.c_mock = unittest.mock.Mock() + self.c = DummyConversation(self.c_mock, self.svc, parent=self.parent) + + def tearDown(self): + del self.c + del self.parent + del self.cc + + def test__client(self): + self.assertEqual( + self.c._client, + self.cc, + ) + + def test__service(self): + self.assertEqual( + self.c._service, + self.svc, + ) + + def test_parent(self): + self.assertEqual( + self.c.parent, + self.parent, + ) + + def test_parent_is_not_writable(self): + with self.assertRaises(AttributeError): + self.c.parent = self.c.parent + + def test_leave_calls_conversation_left_on_service(self): + run_coroutine(self.c.leave()) + self.svc._conversation_left.assert_called_once_with(self.c) + + def test_send_message_calls_send_message_tracked_and_cancels_tracking(self): + run_coroutine(self.c.send_message(unittest.mock.sentinel.body)) + self.c_mock.send_message_tracked.assert_called_once_with( + unittest.mock.sentinel.body, + ) + self.c_mock.send_message_tracked().cancel.assert_called_once_with() diff --git a/tests/im/test_p2p.py b/tests/im/test_p2p.py new file mode 100644 index 00000000..f75e1abe --- /dev/null +++ b/tests/im/test_p2p.py @@ -0,0 +1,505 @@ +######################################################################## +# File name: test_p2p.py +# This file is part of: aioxmpp +# +# LICENSE +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this program. If not, see +# . +# +######################################################################## +import asyncio +import contextlib +import unittest + +import aioxmpp +import aioxmpp.service + +import aioxmpp.im.p2p as p2p + +from aioxmpp.testutils import ( + make_connected_client, + CoroutineMock, + run_coroutine, +) + +from aioxmpp.e2etest import ( + blocking_timed, + TestCase, +) + + +LOCAL_JID = aioxmpp.JID.fromstr("juliet@capulet.example/balcony") +PEER_JID = aioxmpp.JID.fromstr("romeo@montague.example") + + +class TestConversation(unittest.TestCase): + def setUp(self): + self.listener = unittest.mock.Mock() + + self.cc = make_connected_client() + self.cc.stream.send = CoroutineMock() + self.cc.local_jid = LOCAL_JID + self.svc = unittest.mock.Mock(["client", "_conversation_left"]) + self.svc.client = self.cc + + self.c = p2p.Conversation(self.svc, PEER_JID) + + for ev in ["on_message_received"]: + listener = getattr(self.listener, ev) + signal = getattr(self.c, ev) + listener.return_value = None + signal.connect(listener) + + def tearDown(self): + del self.cc + + def test_registers_message_handler(self): + self.cc.stream.register_message_callback.assert_called_once_with( + None, + PEER_JID, + self.c._Conversation__inbound_message, + ) + + def test_members_contain_both_entities(self): + members = list(self.c.members) + self.assertCountEqual( + [PEER_JID, LOCAL_JID], + [member.conversation_jid for member in members] + ) + + self.assertCountEqual( + [True, False], + [member.is_self for member in members] + ) + + self.assertSequenceEqual( + [member.direct_jid for member in members], + [member.conversation_jid for member in members] + ) + + def test_me(self): + self.assertIn(self.c.me, self.c.members) + self.assertEqual( + self.c.me.direct_jid, + LOCAL_JID, + ) + self.assertTrue( + self.c.me.is_self + ) + + def test_send_message_stamps_to_and_sends(self): + msg = unittest.mock.Mock() + run_coroutine(self.c.send_message(msg)) + + self.cc.stream.send.assert_called_once_with(msg) + self.assertEqual(msg.to, PEER_JID) + + def test_inbound_message_dispatched_to_event(self): + msg = unittest.mock.sentinel.message + self.c._Conversation__inbound_message(msg) + self.listener.on_message_received.assert_called_once_with( + msg, + ) + + def test_leave_disconnects_handler(self): + run_coroutine(self.c.leave()) + self.cc.stream.unregister_message_callback.assert_called_once_with( + None, + PEER_JID, + ) + + def test_leave_calls_conversation_left(self): + run_coroutine(self.c.leave()) + self.svc._conversation_left.assert_called_once_with(self.c) + + def test_peer_jid(self): + self.assertEqual( + self.c.peer_jid, + PEER_JID, + ) + + def test_peer_jid_not_writable(self): + with self.assertRaises(AttributeError): + self.c.peer_jid = self.c.peer_jid + + def test_message_tracking_not_implemented(self): + with self.assertRaises(NotImplementedError): + run_coroutine(self.c.send_message_tracked( + unittest.mock.sentinel.foo + )) + + +class TestService(unittest.TestCase): + def setUp(self): + self.cc = make_connected_client() + self.cc.stream.send = CoroutineMock() + self.cc.stream.send.side_effect = AssertionError("not configured") + self.cc.local_jid = LOCAL_JID + self.svc = unittest.mock.Mock(["client", "_conversation_left"]) + self.svc.client = self.cc + self.s = p2p.Service(self.cc) + + self.listener = unittest.mock.Mock() + + for ev in ["on_conversation_new", "on_conversation_left"]: + listener = getattr(self.listener, ev) + signal = getattr(self.s, ev) + listener.return_value = None + signal.connect(listener) + + def tearDown(self): + del self.cc + + def test_get_conversation_creates_conversation(self): + with contextlib.ExitStack() as stack: + Conversation = stack.enter_context(unittest.mock.patch( + "aioxmpp.im.p2p.Conversation" + )) + + c = run_coroutine(self.s.get_conversation(PEER_JID)) + + self.cc.stream.register_message_callback.assert_not_called() + + Conversation.assert_called_once_with( + self.s, + PEER_JID, + parent=None, + ) + + self.assertEqual( + c, + Conversation(), + ) + + def test_get_conversation_deduplicates(self): + with contextlib.ExitStack() as stack: + Conversation = stack.enter_context(unittest.mock.patch( + "aioxmpp.im.p2p.Conversation" + )) + + c1 = run_coroutine(self.s.get_conversation(PEER_JID)) + c2 = run_coroutine(self.s.get_conversation(PEER_JID)) + + Conversation.assert_called_once_with( + self.s, + PEER_JID, + parent=None, + ) + + self.assertIs(c1, c2) + + def test_get_conversation_returns_fresh_after_leave(self): + def generate_mocks(): + while True: + yield unittest.mock.Mock() + + with contextlib.ExitStack() as stack: + Conversation = stack.enter_context(unittest.mock.patch( + "aioxmpp.im.p2p.Conversation", + )) + Conversation.side_effect = generate_mocks() + + c1 = run_coroutine(self.s.get_conversation(PEER_JID)) + c1.peer_jid = PEER_JID + self.s._conversation_left(c1) + c2 = run_coroutine(self.s.get_conversation(PEER_JID)) + + self.assertIsNot(c1, c2) + + def test_get_conversation_emits_on_conversation_new_and_left(self): + def generate_mocks(): + while True: + yield unittest.mock.Mock() + + with contextlib.ExitStack() as stack: + Conversation = stack.enter_context(unittest.mock.patch( + "aioxmpp.im.p2p.Conversation", + )) + Conversation.side_effect = generate_mocks() + + c1 = run_coroutine(self.s.get_conversation(PEER_JID)) + self.listener.on_conversation_new.assert_called_once_with(c1) + c1.peer_jid = PEER_JID + self.s._conversation_left(c1) + self.listener.on_conversation_left.assert_called_once_with(c1) + c2 = run_coroutine(self.s.get_conversation(PEER_JID)) + self.listener.on_conversation_new.assert_called_with(c2) + + self.assertIsNot(c1, c2) + + def test_has_message_filter(self): + self.assertTrue( + aioxmpp.service.is_inbound_message_filter( + p2p.Service._filter_inbound_message, + ) + ) + + def test_message_filter_passes_stanzas(self): + stanza = unittest.mock.Mock(["type_", "to", "from_", "id_"]) + self.assertIs( + self.s._filter_inbound_message(stanza), + stanza, + ) + + def test_autocreate_conversation_from_chat_with_body(self): + msg = aioxmpp.Message( + type_=aioxmpp.MessageType.CHAT, + from_=PEER_JID.replace(resource="foo"), + ) + msg.body[None] = "foo" + + with contextlib.ExitStack() as stack: + Conversation = stack.enter_context(unittest.mock.patch( + "aioxmpp.im.p2p.Conversation", + )) + + self.assertIs(self.s._filter_inbound_message(msg), msg) + Conversation.assert_called_once_with( + self.s, + msg.from_.bare(), + parent=None + ) + + c = run_coroutine(self.s.get_conversation(PEER_JID)) + Conversation.assert_called_once_with( + self.s, + msg.from_.bare(), + parent=None + ) + + self.assertEqual(c, Conversation()) + + self.listener.on_conversation_new.assert_called_once_with( + Conversation() + ) + + def test_autocreate_conversation_from_normal_with_body(self): + msg = aioxmpp.Message( + type_=aioxmpp.MessageType.NORMAL, + from_=PEER_JID.replace(resource="foo"), + ) + msg.body[None] = "foo" + + with contextlib.ExitStack() as stack: + Conversation = stack.enter_context(unittest.mock.patch( + "aioxmpp.im.p2p.Conversation", + )) + + self.assertIs(self.s._filter_inbound_message(msg), msg) + Conversation.assert_called_once_with( + self.s, + msg.from_.bare(), + parent=None + ) + + c = run_coroutine(self.s.get_conversation(PEER_JID)) + Conversation.assert_called_once_with( + self.s, + msg.from_.bare(), + parent=None + ) + + self.assertEqual(c, Conversation()) + + self.listener.on_conversation_new.assert_called_once_with( + Conversation() + ) + + def test_no_autocreate_conversation_from_groupchat_with_body(self): + msg = aioxmpp.Message( + type_=aioxmpp.MessageType.GROUPCHAT, + from_=PEER_JID.replace(resource="foo"), + ) + msg.body[None] = "foo" + + with contextlib.ExitStack() as stack: + Conversation = stack.enter_context(unittest.mock.patch( + "aioxmpp.im.p2p.Conversation", + )) + + self.assertIs(self.s._filter_inbound_message(msg), msg) + Conversation.assert_not_called() + self.listener.on_conversation_new.assert_not_called() + + def test_no_autocreate_conversation_from_error_with_body(self): + msg = aioxmpp.Message( + type_=aioxmpp.MessageType.ERROR, + from_=PEER_JID.replace(resource="foo"), + ) + msg.body[None] = "foo" + + with contextlib.ExitStack() as stack: + Conversation = stack.enter_context(unittest.mock.patch( + "aioxmpp.im.p2p.Conversation", + )) + + self.assertIs(self.s._filter_inbound_message(msg), msg) + Conversation.assert_not_called() + self.listener.on_conversation_new.assert_not_called() + + def test_no_autocreate_conversation_from_other_with_body(self): + msg = unittest.mock.Mock(["type_", "from_", "body"]) + + with contextlib.ExitStack() as stack: + Conversation = stack.enter_context(unittest.mock.patch( + "aioxmpp.im.p2p.Conversation", + )) + + self.assertIs(self.s._filter_inbound_message(msg), msg) + Conversation.assert_not_called() + self.listener.on_conversation_new.assert_not_called() + + def test_no_autocreate_conversation_from_normal_without_body(self): + msg = aioxmpp.Message( + type_=aioxmpp.MessageType.NORMAL, + from_=PEER_JID.replace(resource="foo"), + ) + + with contextlib.ExitStack() as stack: + Conversation = stack.enter_context(unittest.mock.patch( + "aioxmpp.im.p2p.Conversation", + )) + + self.assertIs(self.s._filter_inbound_message(msg), msg) + Conversation.assert_not_called() + self.listener.on_conversation_new.assert_not_called() + + def test_no_autocreate_conversation_from_chat_without_body(self): + msg = aioxmpp.Message( + type_=aioxmpp.MessageType.CHAT, + from_=PEER_JID.replace(resource="foo"), + ) + + with contextlib.ExitStack() as stack: + Conversation = stack.enter_context(unittest.mock.patch( + "aioxmpp.im.p2p.Conversation", + )) + + self.assertIs(self.s._filter_inbound_message(msg), msg) + Conversation.assert_not_called() + self.listener.on_conversation_new.assert_not_called() + + +class TestE2E(TestCase): + @blocking_timed + @asyncio.coroutine + def setUp(self): + services = [p2p.Service] + + self.firstwitch, self.secondwitch, self.thirdwitch = \ + yield from asyncio.gather( + self.provisioner.get_connected_client( + services=services + ), + self.provisioner.get_connected_client( + services=services + ), + self.provisioner.get_connected_client( + services=services + ), + ) + + @blocking_timed + @asyncio.coroutine + def test_converse_with_preexisting(self): + c1 = yield from self.firstwitch.summon(p2p.Service).get_conversation( + self.secondwitch.local_jid.bare() + ) + + c2 = yield from self.secondwitch.summon(p2p.Service).get_conversation( + self.firstwitch.local_jid.bare() + ) + + fwmsgs = [] + fwev = asyncio.Event() + + def fwevset(*args): + fwev.set() + + swmsgs = [] + swev = asyncio.Event() + + def swevset(*args): + swev.set() + + c1.on_message_received.connect(fwmsgs.append) + c1.on_message_received.connect(fwevset) + c2.on_message_received.connect(swmsgs.append) + c2.on_message_received.connect(swevset) + + msg = aioxmpp.Message(aioxmpp.MessageType.CHAT) + msg.body[None] = "foo" + yield from c1.send_message(msg) + yield from swev.wait() + + self.assertEqual(len(swmsgs), 1) + self.assertEqual(swmsgs[0].body[None], "foo") + self.assertEqual(len(fwmsgs), 0) + + msg.body[None] = "bar" + yield from c2.send_message(msg) + yield from fwev.wait() + + self.assertEqual(len(fwmsgs), 1) + self.assertEqual(fwmsgs[0].body[None], "bar") + self.assertEqual(len(swmsgs), 1) + + # @blocking_timed + # @asyncio.coroutine + # def test_autocreate_conversation(self): + # svc1 = self.firstwitch.summon(p2p.Service) + + # c1 = None + + # def new_conversation(conv): + # nonlocal c1 + # c1 = conv + # c1.on_message_received.connect(fwmsgs.append) + # c1.on_message_received.connect(fwevset) + + # svc1.on_conversation_new.connect(new_conversation) + + # c2 = yield from self.secondwitch.summon(p2p.Service).get_conversation( + # self.firstwitch.local_jid.bare() + # ) + + # fwmsgs = [] + # fwev = asyncio.Event() + + # def fwevset(*args): + # fwev.set() + + # swmsgs = [] + # swev = asyncio.Event() + + # def swevset(*args): + # swev.set() + + # c2.on_message_received.connect(swmsgs.append) + # c2.on_message_received.connect(swevset) + + # msg = aioxmpp.Message(aioxmpp.MessageType.CHAT) + # msg.body[None] = "foo" + # yield from c2.send_message(msg) + # yield from fwev.wait() + + # self.assertIsNotNone(c1) + # self.assertIs( + # c1, + # (yield from svc1.get_conversation(self.secondwitch.local_jid)), + # ) + + # self.assertEqual(len(fwmsgs), 1) + # self.assertEqual(fwmsgs[0].body[None], "foo") From 3ad86f6d4b4766b6c5f8c34bf5c6cc2910be8c8d Mon Sep 17 00:00:00 2001 From: Jonas Wielicki Date: Thu, 6 Apr 2017 14:53:35 +0200 Subject: [PATCH 06/40] im: Slight modifications of AbstractConversation --- aioxmpp/im/__init__.py | 3 +++ aioxmpp/im/conversation.py | 2 ++ 2 files changed, 5 insertions(+) diff --git a/aioxmpp/im/__init__.py b/aioxmpp/im/__init__.py index 7521e110..ef366997 100644 --- a/aioxmpp/im/__init__.py +++ b/aioxmpp/im/__init__.py @@ -37,6 +37,9 @@ Communication context for two or more parties. :term:`Conversation Member` An entity taking part in a :term:`Conversation`. +:term:`Conversation Implementation` + A :term:`Service` which provides means to create and manage specific + :class:`~.AbstractConversation` subclasses. Enumerations ============ diff --git a/aioxmpp/im/conversation.py b/aioxmpp/im/conversation.py index 6b471eee..32424484 100644 --- a/aioxmpp/im/conversation.py +++ b/aioxmpp/im/conversation.py @@ -253,7 +253,9 @@ class AbstractConversation(metaclass=abc.ABCMeta): """ on_message_received = aioxmpp.callbacks.Signal() + on_message_sent = aioxmpp.callbacks.Signal() on_state_changed = aioxmpp.callbacks.Signal() + on_exit = aioxmpp.callbacks.Signal() def __init__(self, service, parent=None, **kwargs): super().__init__(**kwargs) From b3b31f5248b1f934b00b165e6f3ac510f59ae583 Mon Sep 17 00:00:00 2001 From: Jonas Wielicki Date: Thu, 6 Apr 2017 14:54:29 +0200 Subject: [PATCH 07/40] im: First draft for one-on-one conversations --- aioxmpp/im/muc.py | 26 ++++++++++ aioxmpp/im/p2p.py | 9 ++++ aioxmpp/im/service.py | 103 +++++++++++++++++++++++++++++++++++++++ tests/im/test_p2p.py | 30 +++++++++++- tests/im/test_service.py | 76 +++++++++++++++++++++++++++++ 5 files changed, 243 insertions(+), 1 deletion(-) create mode 100644 aioxmpp/im/muc.py create mode 100644 aioxmpp/im/service.py create mode 100644 tests/im/test_service.py diff --git a/aioxmpp/im/muc.py b/aioxmpp/im/muc.py new file mode 100644 index 00000000..0254973e --- /dev/null +++ b/aioxmpp/im/muc.py @@ -0,0 +1,26 @@ +######################################################################## +# File name: muc.py +# This file is part of: aioxmpp +# +# LICENSE +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this program. If not, see +# . +# +######################################################################## +import aioxmpp.service + + +class MUCIMService(aioxmpp.service.Service): + pass diff --git a/aioxmpp/im/p2p.py b/aioxmpp/im/p2p.py index f8ffea72..d2bb6be6 100644 --- a/aioxmpp/im/p2p.py +++ b/aioxmpp/im/p2p.py @@ -29,6 +29,8 @@ AbstractConversationService, ) +from .service import ConversationService + class Member(AbstractConversationMember): def __init__(self, peer_jid, is_self): @@ -71,6 +73,7 @@ def me(self): @asyncio.coroutine def send_message(self, msg): msg.to = self.__peer_jid + self.on_message_sent(msg) yield from self._client.stream.send(msg) @asyncio.coroutine @@ -113,13 +116,19 @@ class Service(AbstractConversationService, aioxmpp.service.Service): """ + ORDER_AFTER = [ConversationService] + def __init__(self, client, **kwargs): super().__init__(client, **kwargs) self._conversationmap = {} + self.on_conversation_new.connect( + self.dependencies[ConversationService]._add_conversation + ) def _make_conversation(self, peer_jid): result = Conversation(self, peer_jid, parent=None) self._conversationmap[peer_jid] = result + print("_make_conversation", peer_jid) self.on_conversation_new(result) return result diff --git a/aioxmpp/im/service.py b/aioxmpp/im/service.py new file mode 100644 index 00000000..36b2522f --- /dev/null +++ b/aioxmpp/im/service.py @@ -0,0 +1,103 @@ +######################################################################## +# File name: service.py +# This file is part of: aioxmpp +# +# LICENSE +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this program. If not, see +# . +# +######################################################################## +import functools + +import aioxmpp.callbacks +import aioxmpp.service + + +class ConversationService(aioxmpp.service.Service): + """ + Central place where all :class:`.im.conversation.AbstractConversation` + subclass instances are collected. + + It provides discoverability of all existing conversations (in no particular + order) and signals on addition and removal of active conversations. This is + useful for front ends to track conversations globally without needing to + know about the specific conversation providers. + + .. signal:: on_conversation_added(conversation) + + A new conversation has been added. + + :param conversation: The conversation which was added. + :type conversation: :class:`~.im.conversation.AbstractConversation` + + This signal is fired when a new conversation is added by a + :term:`Conversation Implementation`. + + .. note:: + + If you are looking for a "on_conversation_removed" event or similar, + there is none. You should use the + :meth:`.AbstractConversation.on_exit` event of the `conversation`. + + .. autoattribute:: conversations + + For :term:`Conversation Implementations `, the + following methods are intended; they should not be used by applications. + + .. automethod:: _add_conversation + + """ + + on_conversation_added = aioxmpp.callbacks.Signal() + + def __init__(self, client, **kwargs): + super().__init__(client, **kwargs) + self._conversations = [] + + @property + def conversations(self): + """ + Return an iterable of conversations in which the local client is + participating. + """ + return self._conversations + + def _handle_conversation_exit(self, conv, *args, **kwargs): + self._conversations.remove(conv) + + def _add_conversation(self, conversation): + """ + Add the conversation and fire the :meth:`on_conversation_added` event. + + :param conversation: The conversation object to add. + :type conversation: :class:`~.AbstractConversation` + + The conversation is added to the internal list of conversations which + can be queried at :attr:`conversations`. The + :meth:`on_conversation_added` event is fired. + + In addition, the :class:`ConversationService` subscribes to the + :meth:`~.AbstractConversation.on_exit` event to remove the conversation + from the list automatically. There is no need to remove a conversation + from the list explicitly. + """ + self.on_conversation_added(conversation) + conversation.on_exit.connect( + functools.partial( + self._handle_conversation_exit, + conversation + ), + ) + self._conversations.append(conversation) diff --git a/tests/im/test_p2p.py b/tests/im/test_p2p.py index f75e1abe..0d6f5b48 100644 --- a/tests/im/test_p2p.py +++ b/tests/im/test_p2p.py @@ -27,6 +27,7 @@ import aioxmpp.service import aioxmpp.im.p2p as p2p +import aioxmpp.im.service as im_service from aioxmpp.testutils import ( make_connected_client, @@ -147,9 +148,14 @@ def setUp(self): self.cc.stream.send = CoroutineMock() self.cc.stream.send.side_effect = AssertionError("not configured") self.cc.local_jid = LOCAL_JID + deps = { + im_service.ConversationService: im_service.ConversationService( + self.cc + ) + } self.svc = unittest.mock.Mock(["client", "_conversation_left"]) self.svc.client = self.cc - self.s = p2p.Service(self.cc) + self.s = p2p.Service(self.cc, dependencies=deps) self.listener = unittest.mock.Mock() @@ -159,9 +165,21 @@ def setUp(self): listener.return_value = None signal.connect(listener) + for ev in ["on_conversation_added"]: + listener = getattr(self.listener, ev) + signal = getattr(deps[im_service.ConversationService], ev) + listener.return_value = None + signal.connect(listener) + def tearDown(self): del self.cc + def test_depends_on_conversation_service(self): + self.assertLess( + im_service.ConversationService, + p2p.Service, + ) + def test_get_conversation_creates_conversation(self): with contextlib.ExitStack() as stack: Conversation = stack.enter_context(unittest.mock.patch( @@ -183,6 +201,16 @@ def test_get_conversation_creates_conversation(self): Conversation(), ) + def test_get_conversation_emits_event(self): + with contextlib.ExitStack() as stack: + stack.enter_context(unittest.mock.patch( + "aioxmpp.im.p2p.Conversation" + )) + + c = run_coroutine(self.s.get_conversation(PEER_JID)) + + self.listener.on_conversation_added.assert_called_once_with(c) + def test_get_conversation_deduplicates(self): with contextlib.ExitStack() as stack: Conversation = stack.enter_context(unittest.mock.patch( diff --git a/tests/im/test_service.py b/tests/im/test_service.py new file mode 100644 index 00000000..64406688 --- /dev/null +++ b/tests/im/test_service.py @@ -0,0 +1,76 @@ +######################################################################## +# File name: test_service.py +# This file is part of: aioxmpp +# +# LICENSE +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this program. If not, see +# . +# +######################################################################## +import unittest + +import aioxmpp.im.service as im_service + +from aioxmpp.testutils import ( + make_connected_client, +) + + +class TestConversationService(unittest.TestCase): + def setUp(self): + self.listener = unittest.mock.Mock() + self.cc = make_connected_client() + self.s = im_service.ConversationService(self.cc) + + for ev in ["on_conversation_added"]: + handler = getattr(self.listener, ev) + handler.return_value = None + getattr(self.s, ev).connect(handler) + + def tearDown(self): + del self.s + del self.cc + + def test_init(self): + self.assertSequenceEqual( + list(self.s.conversations), + [], + ) + + def test__add_conversation(self): + conv = unittest.mock.Mock() + self.s._add_conversation(conv) + self.listener.on_conversation_added.assert_called_once_with(conv) + conv.on_exit.connect.assert_called_once_with( + unittest.mock.ANY + ) + + self.assertCountEqual( + self.s.conversations, + [ + conv, + ] + ) + + (_, (cb, ), _), = conv.on_exit.mock_calls + + # should ignore its arguments + cb(unittest.mock.sentinel.foo, bar=unittest.mock.sentinel.fnord) + + self.assertCountEqual( + self.s.conversations, + [ + ] + ) From 8c348b9b40c0a237e6b22bd1d32803bda0e599cb Mon Sep 17 00:00:00 2001 From: Jonas Wielicki Date: Tue, 28 Mar 2017 15:00:18 +0200 Subject: [PATCH 08/40] im: Create IMDispatcher message dispatcher class --- aioxmpp/im/dispatcher.py | 105 ++++++++++++++++++++++++++++ docs/message-dispatching.rst | 69 +++++++++++++++++++ tests/im/test_dispatcher.py | 128 +++++++++++++++++++++++++++++++++++ 3 files changed, 302 insertions(+) create mode 100644 aioxmpp/im/dispatcher.py create mode 100644 docs/message-dispatching.rst create mode 100644 tests/im/test_dispatcher.py diff --git a/aioxmpp/im/dispatcher.py b/aioxmpp/im/dispatcher.py new file mode 100644 index 00000000..e21e6183 --- /dev/null +++ b/aioxmpp/im/dispatcher.py @@ -0,0 +1,105 @@ +######################################################################## +# File name: dispatcher.py +# This file is part of: aioxmpp +# +# LICENSE +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this program. If not, see +# . +# +######################################################################## +import enum + +import aioxmpp.callbacks +import aioxmpp.service +import aioxmpp.stream + + +class MessageSource(enum.Enum): + STREAM = 0 + CARBONS = 1 + + +class IMDispatcher(aioxmpp.service.Service): + """ + Dispatches messages, taking into account carbons. + + .. signal:: on_message(message, sent, source) + + A message was received or sent. + + :param message: Message stanza + :type message: :class:`aioxmpp.Message` + :param sent: Whether the mesasge was sent or received. + :type sent: :class:`bool` + :param source: The source of the message. + :type source: :class:`MessageSource` + + `message` is the message stanza which was sent or received. + + If `sent` is true, the message was sent from this resource *or* another + resource of the same account, if Message Carbons are enabled. + + `source` indicates how the message was sent or received. It may be one + of the values of the :class:`MessageSource` enumeration. + + """ + + ORDER_AFTER = [ + # we want to be loaded after the SimplePresenceDispatcher to ensure + # that PresenceClient has updated its data structures before the + # dispatch_presence handler runs. + # this helps one-to-one conversations a lot, because they can simply + # re-use the PresenceClient state + aioxmpp.dispatcher.SimplePresenceDispatcher, + ] + + def __init__(self, client, **kwargs): + super().__init__(client, **kwargs) + self.message_filter = aioxmpp.callbacks.Filter() + self.presence_filter = aioxmpp.callbacks.Filter() + + @aioxmpp.service.depsignal( + aioxmpp.stream.StanzaStream, + "on_message_received") + def dispatch_message(self, message, *, + sent=False, + source=MessageSource.STREAM): + filtered = self.message_filter.filter( + message, + sent, + source, + ) + + if filtered is not None: + self.logger.debug( + "message was not processed by any IM handler: %s", + filtered, + ) + + @aioxmpp.service.depsignal( + aioxmpp.stream.StanzaStream, + "on_presence_received") + def dispatch_presence(self, presence, *, sent=False): + filtered = self.presence_filter.filter( + presence, + presence.from_, + sent, + ) + + if filtered is not None: + self.logger.debug( + "presence was not processed by any IM handler: %s", + filtered, + ) diff --git a/docs/message-dispatching.rst b/docs/message-dispatching.rst new file mode 100644 index 00000000..e2be9e56 --- /dev/null +++ b/docs/message-dispatching.rst @@ -0,0 +1,69 @@ +Message Dispatching Rewrite +########################### + +Issues with the current system: + +* Precedence of wildcarding is not clear +* No distinction between "wildcard for a bare JID + all of its resources" and "only the bare JID" (it’s always the wildcard) +* Precedence is important as stanzas are delivered to only exactly one handler. + + +Proposed solution +================= + +* Break the message dispatching out of the StanzaStream for easier re-writing. +* Handle it in a separate class. +* Allow applications to configure which message dispatcher is used. + +This allows for: + +* Creation of a mesasge dispatcher specialised for Instant Messaging. It could + allow for out-of-band flags for messages, e.g. "Sent—Carbon", + "Received-Carbon", …. The interface could be internal so that we can easily + add features. + +* Implement the previous behaviour in a separate class. It would provide a + stable interface for applications not focused on the concept of a + conversation. + + +Issues +====== + +* While this gives us nice features, we may want to be able to support both + styles at the same time. This may lead to duplicate messages throughout + different services, which is annoying and potentially bad. + + Workaround: Make these dispatchers work filter-style: if they have handled the + message, the message is dropped from dispatching. + + -> needs priorities for dispatchers. User-controlled or dispatcher-controlled? + + +Interface for Message Dispatchers +================================= + + +.. class:: AbstractMessageDispatcher + + .. method:: handle_message(stanza) + + Called by the stanza stream when a message is received and has passed + stream-level filters. + + + + + +Transition path for existing StanzaStream methods +================================================= + +1. Allow multiple Message Dispatchers, have a default one which provides that + interface and make the methods simply redirect there. + +2. Allow only a single Message Dispatcher and create a legacy one by default. + Use the methods to redirect there. Fail loudly when the message dispatcher + has been changed (or when it is being changed and there are still callbacks + registered). + +3. Delete them right away. diff --git a/tests/im/test_dispatcher.py b/tests/im/test_dispatcher.py new file mode 100644 index 00000000..e334add3 --- /dev/null +++ b/tests/im/test_dispatcher.py @@ -0,0 +1,128 @@ +######################################################################## +# File name: test_dispatcher.py +# This file is part of: aioxmpp +# +# LICENSE +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this program. If not, see +# . +# +######################################################################## +import unittest + +import aioxmpp.callbacks +import aioxmpp.muc +import aioxmpp.im.dispatcher as dispatcher +import aioxmpp.service +import aioxmpp.stream + +from aioxmpp.testutils import ( + make_connected_client, +) + + +TEST_LOCAL = aioxmpp.JID.fromstr("foo@service.example") +TEST_PEER = aioxmpp.JID.fromstr("bar@service.example") + + +class TestIMDispatcher(unittest.TestCase): + def setUp(self): + self.cc = make_connected_client() + self.s = dispatcher.IMDispatcher(self.cc) + self.listener = unittest.mock.Mock() + + for filter_ in ["message_filter", "presence_filter"]: + cb = getattr(self.listener, filter_) + cb.side_effect = lambda x, *args, **kwargs: x + filter_chain = getattr(self.s, filter_) + filter_chain.register(cb, 0) + + def tearDown(self): + del self.s + del self.cc + + def test_message_filter_is_filter(self): + self.assertIsInstance( + self.s.message_filter, + aioxmpp.callbacks.Filter, + ) + + def test_presence_filter_is_filter(self): + self.assertIsInstance( + self.s.presence_filter, + aioxmpp.callbacks.Filter, + ) + + def test_orders_after_simple_presence_dispatcher(self): + self.assertIn( + aioxmpp.dispatcher.SimplePresenceDispatcher, + dispatcher.IMDispatcher.ORDER_AFTER, + ) + + def test_dispatch_message_listens_to_on_message_received(self): + self.assertTrue( + aioxmpp.service.is_depsignal_handler( + aioxmpp.stream.StanzaStream, + "on_message_received", + dispatcher.IMDispatcher.dispatch_message, + ) + ) + + def test_dispatch_presence_listens_to_on_presence_received(self): + self.assertTrue( + aioxmpp.service.is_depsignal_handler( + aioxmpp.stream.StanzaStream, + "on_presence_received", + dispatcher.IMDispatcher.dispatch_presence, + ) + ) + + def test_dispatch_simple_messages(self): + msg = aioxmpp.Message( + type_=aioxmpp.MessageType.CHAT + ) + + self.s.dispatch_message(msg) + + self.listener.message_filter.assert_called_once_with( + msg, + False, + dispatcher.MessageSource.STREAM, + ) + + def test_dispatch_presences(self): + types = [ + (aioxmpp.PresenceType.AVAILABLE, True), + (aioxmpp.PresenceType.UNAVAILABLE, True), + (aioxmpp.PresenceType.ERROR, True), + (aioxmpp.PresenceType.SUBSCRIBE, False), + (aioxmpp.PresenceType.UNSUBSCRIBE, False), + (aioxmpp.PresenceType.SUBSCRIBED, False), + (aioxmpp.PresenceType.UNSUBSCRIBED, False), + ] + + for type_, dispatch in types: + pres = aioxmpp.Presence( + type_=type_, + from_=TEST_PEER, + to=TEST_LOCAL, + ) + + self.s.dispatch_presence(pres) + + self.listener.presence_filter.assert_called_once_with( + pres, + TEST_PEER, + False, + ) From 1658392bd33e9af569f2e7ab57ac7118978d3a89 Mon Sep 17 00:00:00 2001 From: Jonas Wielicki Date: Wed, 29 Mar 2017 13:28:41 +0200 Subject: [PATCH 09/40] im: Adopt one-on-one Conversation to use IMDispatcher --- aioxmpp/im/conversation.py | 99 ++++++---------- aioxmpp/im/dispatcher.py | 11 +- aioxmpp/im/p2p.py | 40 +++---- tests/im/test_dispatcher.py | 46 +++++--- tests/im/test_p2p.py | 224 +++++++++++++++++++++++------------- 5 files changed, 242 insertions(+), 178 deletions(-) diff --git a/aioxmpp/im/conversation.py b/aioxmpp/im/conversation.py index 32424484..ac0a97c4 100644 --- a/aioxmpp/im/conversation.py +++ b/aioxmpp/im/conversation.py @@ -31,71 +31,6 @@ class InviteMode(enum.Enum): MEDIATED = 1 -# class AbstractConversationMember(metaclass=abc.ABCMeta): -# """ -# Interface for a member in a conversation. - -# The JIDs of a member can be either bare or full. Both bare and full JIDs -# can be used with the :class:`aioxmpp.PresenceClient` service to look up the -# presence information. - -# .. autoattribute:: direct_jid - -# .. autoattribute:: conversation_jid - -# .. autoattribute:: root_conversation - -# .. automethod:: get_direct_conversation -# """ - -# @abc.abstractproperty -# def direct_jid(self): -# """ -# A :class:`aioxmpp.JID` which can be used to directly communicate with -# the member. - -# May be :data:`None` if the direct JID is not known or has not been -# explicitly requested for the conversation. -# """ - -# @abc.abstractproperty -# def conversation_jid(self): -# """ -# A :class:`~aioxmpp.JID` which can be used to communicate with the -# member in the context of the conversation. -# """ - -# @abc.abstractproperty -# def root_conversation(self): -# """ -# The root conversation to which this member belongs. -# """ - -# def _not_implemented_error(self, what): -# return NotImplementedError( -# "{} not supported for this type of conversation".format(what) -# ) - -# @asyncio.coroutine -# def get_direct_conversation(self, *, prefer_direct=True): -# """ -# Create or get and return a direct conversation with this member. - -# :param prefer_direct: Control which JID is used to start the -# conversation. -# :type prefer_direct: :class:`bool` -# :raises NotImplementedError: if a direct conversation is not supported -# for the type of conversation to which the -# member belongs. -# :return: New or existing conversation with the conversation member. -# :rtype: :class:`.AbstractConversation` - -# This may not be available for all conversation implementations. If it -# is not available, :class:`NotImplementedError` is raised. -# """ -# raise self._not_implemented_error("direct conversation") - - class ConversationState(enum.Enum): """ State of a conversation. @@ -198,14 +133,44 @@ class AbstractConversation(metaclass=abc.ABCMeta): Signals: - .. signal:: on_message_received(msg, member) + .. signal:: on_message(msg, member, source) - A message has been received within the conversation. + A message occured in the conversation. :param msg: Message which was received. :type msg: :class:`aioxmpp.Message` :param member: The member object of the sender. :type member: :class:`.AbstractConversationMember` + :param source: How the message was acquired + :type source: :class:`~.MessageSource` + + This signal is emitted on the following events: + + * A message was sent to the conversation and delivered directly to us. + This is the classic case of "a message was received". In this case, + `source` is :attr:`~.MessageSource.STREAM` and `member` is the + :class:`~.AbstractConversationMember` of the originator. + + * A message was sent from this client. This is the classic case of "a + message was sent". In this case, `source` is + :attr:`~.MessageSource.STREAM` and `member` refers to ourselves. + + * A carbon-copy of a message received by another resource of our account + which belongs to this conversation was received. `source` is + :attr:`~.MessageSource.CARBONS` and `member` is the + :class:`~.AbstractConversationMember` of the originator. + + * A carbon-copy of a message sent by another resource of our account was + sent to this conversation. In this case, `source` is + :attr:`~.MessageSource.CARBONS` and `member` refers to ourselves. + + Often, you don’t need to distinguish between carbon-copied and + non-carbon-copied messages. + + All messages which are not handled otherwise (and for example dispatched + as :meth:`on_state_changed` signals) are dispatched to this event. This + may include messages not understood and/or which carry no textual + payload. .. signal:: on_state_changed(member, new_state, msg) diff --git a/aioxmpp/im/dispatcher.py b/aioxmpp/im/dispatcher.py index e21e6183..88755d80 100644 --- a/aioxmpp/im/dispatcher.py +++ b/aioxmpp/im/dispatcher.py @@ -35,12 +35,14 @@ class IMDispatcher(aioxmpp.service.Service): """ Dispatches messages, taking into account carbons. - .. signal:: on_message(message, sent, source) + .. function:: message_filter(message, peer, sent, source) A message was received or sent. :param message: Message stanza :type message: :class:`aioxmpp.Message` + :param peer: The peer from/to which the stanza was received/sent + :type peer: :class:`aioxmpp.JID` :param sent: Whether the mesasge was sent or received. :type sent: :class:`bool` :param source: The source of the message. @@ -48,6 +50,10 @@ class IMDispatcher(aioxmpp.service.Service): `message` is the message stanza which was sent or received. + `peer` is the JID of the peer involved in the message. If the message + was sent, this is the :attr:`~.StanzaBase.to` and otherwise it is the + :attr:`~.StanzaBase.from_` attribute of the stanza. + If `sent` is true, the message was sent from this resource *or* another resource of the same account, if Message Carbons are enabled. @@ -76,8 +82,11 @@ def __init__(self, client, **kwargs): def dispatch_message(self, message, *, sent=False, source=MessageSource.STREAM): + peer = message.to if sent else message.from_ + filtered = self.message_filter.filter( message, + peer, sent, source, ) diff --git a/aioxmpp/im/p2p.py b/aioxmpp/im/p2p.py index d2bb6be6..6287e56b 100644 --- a/aioxmpp/im/p2p.py +++ b/aioxmpp/im/p2p.py @@ -19,6 +19,7 @@ # . # ######################################################################## + import asyncio import aioxmpp.service @@ -29,6 +30,8 @@ AbstractConversationService, ) +from .dispatcher import IMDispatcher + from .service import ConversationService @@ -49,13 +52,8 @@ def __init__(self, service, peer_jid, parent=None): Member(self._client.local_jid, True), Member(peer_jid, False), ) - self._client.stream.register_message_callback( - None, - self.__peer_jid, - self.__inbound_message, - ) - def __inbound_message(self, msg): + def _handle_message(self, msg, peer, sent, source): self.on_message_received(msg) @property @@ -82,10 +80,6 @@ def send_message_tracked(self, msg): @asyncio.coroutine def leave(self): - self._client.stream.unregister_message_callback( - None, - self.__peer_jid, - ) yield from super().leave() @@ -116,7 +110,10 @@ class Service(AbstractConversationService, aioxmpp.service.Service): """ - ORDER_AFTER = [ConversationService] + ORDER_AFTER = [ + ConversationService, + IMDispatcher, + ] def __init__(self, client, **kwargs): super().__init__(client, **kwargs) @@ -132,21 +129,24 @@ def _make_conversation(self, peer_jid): self.on_conversation_new(result) return result - @aioxmpp.service.inbound_message_filter - def _filter_inbound_message(self, msg): + @aioxmpp.service.depfilter(IMDispatcher, "message_filter") + def _filter_message(self, msg, peer, sent, source): try: - existing = self._conversationmap[msg.from_] + existing = self._conversationmap[peer] except KeyError: try: - existing = self._conversationmap[msg.from_.bare()] + existing = self._conversationmap[peer.bare()] except KeyError: existing = None - if existing is None: - if ((msg.type_ == aioxmpp.MessageType.CHAT or - msg.type_ == aioxmpp.MessageType.NORMAL) and - msg.body): - self._make_conversation(msg.from_.bare()) + if ((msg.type_ == aioxmpp.MessageType.CHAT or + msg.type_ == aioxmpp.MessageType.NORMAL) and + msg.body): + if existing is None: + existing = self._make_conversation(peer.bare()) + + existing._handle_message(msg, peer, sent, source) + return None return msg diff --git a/tests/im/test_dispatcher.py b/tests/im/test_dispatcher.py index e334add3..eabeb1d4 100644 --- a/tests/im/test_dispatcher.py +++ b/tests/im/test_dispatcher.py @@ -88,19 +88,6 @@ def test_dispatch_presence_listens_to_on_presence_received(self): ) ) - def test_dispatch_simple_messages(self): - msg = aioxmpp.Message( - type_=aioxmpp.MessageType.CHAT - ) - - self.s.dispatch_message(msg) - - self.listener.message_filter.assert_called_once_with( - msg, - False, - dispatcher.MessageSource.STREAM, - ) - def test_dispatch_presences(self): types = [ (aioxmpp.PresenceType.AVAILABLE, True), @@ -126,3 +113,36 @@ def test_dispatch_presences(self): TEST_PEER, False, ) + + + def test_dispatch_simple_messages(self): + msg = aioxmpp.Message( + type_=aioxmpp.MessageType.CHAT, + from_=TEST_PEER, + to=TEST_LOCAL, + ) + + self.s.dispatch_message(msg) + + self.listener.message_filter.assert_called_once_with( + msg, + TEST_PEER, + False, + dispatcher.MessageSource.STREAM, + ) + + def test_dispatch_sent_messages(self): + msg = aioxmpp.Message( + type_=aioxmpp.MessageType.CHAT, + from_=TEST_LOCAL, + to=TEST_PEER, + ) + + self.s.dispatch_message(msg, sent=True) + + self.listener.message_filter.assert_called_once_with( + msg, + TEST_PEER, + True, + dispatcher.MessageSource.STREAM, + ) diff --git a/tests/im/test_p2p.py b/tests/im/test_p2p.py index 0d6f5b48..1a1f090f 100644 --- a/tests/im/test_p2p.py +++ b/tests/im/test_p2p.py @@ -28,6 +28,7 @@ import aioxmpp.im.p2p as p2p import aioxmpp.im.service as im_service +import aioxmpp.im.dispatcher as im_dispatcher from aioxmpp.testutils import ( make_connected_client, @@ -66,13 +67,6 @@ def setUp(self): def tearDown(self): del self.cc - def test_registers_message_handler(self): - self.cc.stream.register_message_callback.assert_called_once_with( - None, - PEER_JID, - self.c._Conversation__inbound_message, - ) - def test_members_contain_both_entities(self): members = list(self.c.members) self.assertCountEqual( @@ -109,16 +103,14 @@ def test_send_message_stamps_to_and_sends(self): def test_inbound_message_dispatched_to_event(self): msg = unittest.mock.sentinel.message - self.c._Conversation__inbound_message(msg) - self.listener.on_message_received.assert_called_once_with( + self.c._handle_message( msg, + unittest.mock.sentinel.from_, + False, + im_dispatcher.MessageSource.STREAM ) - - def test_leave_disconnects_handler(self): - run_coroutine(self.c.leave()) - self.cc.stream.unregister_message_callback.assert_called_once_with( - None, - PEER_JID, + self.listener.on_message_received.assert_called_once_with( + msg, ) def test_leave_calls_conversation_left(self): @@ -151,6 +143,9 @@ def setUp(self): deps = { im_service.ConversationService: im_service.ConversationService( self.cc + ), + im_dispatcher.IMDispatcher: im_dispatcher.IMDispatcher( + self.cc ) } self.svc = unittest.mock.Mock(["client", "_conversation_left"]) @@ -180,6 +175,12 @@ def test_depends_on_conversation_service(self): p2p.Service, ) + def test_depends_on_dispatcher_service(self): + self.assertLess( + im_dispatcher.IMDispatcher, + p2p.Service, + ) + def test_get_conversation_creates_conversation(self): with contextlib.ExitStack() as stack: Conversation = stack.enter_context(unittest.mock.patch( @@ -267,21 +268,28 @@ def generate_mocks(): self.assertIsNot(c1, c2) - def test_has_message_filter(self): + def test_has_im_message_filter(self): self.assertTrue( - aioxmpp.service.is_inbound_message_filter( - p2p.Service._filter_inbound_message, + aioxmpp.service.is_depfilter_handler( + im_dispatcher.IMDispatcher, + "message_filter", + p2p.Service._filter_message, ) ) def test_message_filter_passes_stanzas(self): stanza = unittest.mock.Mock(["type_", "to", "from_", "id_"]) self.assertIs( - self.s._filter_inbound_message(stanza), + self.s._filter_message( + stanza, + stanza.from_, + False, + im_dispatcher.MessageSource.STREAM, + ), stanza, ) - def test_autocreate_conversation_from_chat_with_body(self): + def test_autocreate_conversation_from_recvd_chat_with_body(self): msg = aioxmpp.Message( type_=aioxmpp.MessageType.CHAT, from_=PEER_JID.replace(resource="foo"), @@ -293,7 +301,12 @@ def test_autocreate_conversation_from_chat_with_body(self): "aioxmpp.im.p2p.Conversation", )) - self.assertIs(self.s._filter_inbound_message(msg), msg) + self.assertIsNone(self.s._filter_message( + msg, + msg.from_, + False, + im_dispatcher.MessageSource.STREAM, + )) Conversation.assert_called_once_with( self.s, msg.from_.bare(), @@ -313,7 +326,60 @@ def test_autocreate_conversation_from_chat_with_body(self): Conversation() ) - def test_autocreate_conversation_from_normal_with_body(self): + Conversation()._handle_message.assert_called_once_with( + msg, + msg.from_, + False, + im_dispatcher.MessageSource.STREAM, + ) + + def test_autocreate_based_on_peer(self): + msg = aioxmpp.Message( + type_=aioxmpp.MessageType.CHAT, + from_=PEER_JID.replace(resource="foo"), + ) + msg.body[None] = "foo" + + with contextlib.ExitStack() as stack: + Conversation = stack.enter_context(unittest.mock.patch( + "aioxmpp.im.p2p.Conversation", + )) + + self.assertIsNone(self.s._filter_message( + msg, + PEER_JID.replace(localpart="fnord", resource="foo"), + False, + im_dispatcher.MessageSource.STREAM, + )) + Conversation.assert_called_once_with( + self.s, + PEER_JID.replace(localpart="fnord"), + parent=None + ) + + c = run_coroutine(self.s.get_conversation( + PEER_JID.replace(localpart="fnord") + )) + Conversation.assert_called_once_with( + self.s, + PEER_JID.replace(localpart="fnord"), + parent=None + ) + + self.assertEqual(c, Conversation()) + + self.listener.on_conversation_new.assert_called_once_with( + Conversation() + ) + + Conversation()._handle_message.assert_called_once_with( + msg, + PEER_JID.replace(localpart="fnord", resource="foo"), + False, + im_dispatcher.MessageSource.STREAM, + ) + + def test_autocreate_conversation_from_recvd_normal_with_body(self): msg = aioxmpp.Message( type_=aioxmpp.MessageType.NORMAL, from_=PEER_JID.replace(resource="foo"), @@ -325,7 +391,12 @@ def test_autocreate_conversation_from_normal_with_body(self): "aioxmpp.im.p2p.Conversation", )) - self.assertIs(self.s._filter_inbound_message(msg), msg) + self.assertIsNone(self.s._filter_message( + msg, + msg.from_, + False, + im_dispatcher.MessageSource.STREAM, + )) Conversation.assert_called_once_with( self.s, msg.from_.bare(), @@ -345,7 +416,14 @@ def test_autocreate_conversation_from_normal_with_body(self): Conversation() ) - def test_no_autocreate_conversation_from_groupchat_with_body(self): + Conversation()._handle_message.assert_called_once_with( + msg, + msg.from_, + False, + im_dispatcher.MessageSource.STREAM, + ) + + def test_no_autocreate_conversation_from_recvd_groupchat_with_body(self): msg = aioxmpp.Message( type_=aioxmpp.MessageType.GROUPCHAT, from_=PEER_JID.replace(resource="foo"), @@ -357,7 +435,15 @@ def test_no_autocreate_conversation_from_groupchat_with_body(self): "aioxmpp.im.p2p.Conversation", )) - self.assertIs(self.s._filter_inbound_message(msg), msg) + self.assertIs( + self.s._filter_message( + msg, + msg.from_, + False, + im_dispatcher.MessageSource.STREAM, + ), + msg + ) Conversation.assert_not_called() self.listener.on_conversation_new.assert_not_called() @@ -373,7 +459,15 @@ def test_no_autocreate_conversation_from_error_with_body(self): "aioxmpp.im.p2p.Conversation", )) - self.assertIs(self.s._filter_inbound_message(msg), msg) + self.assertIs( + self.s._filter_message( + msg, + msg.from_, + False, + im_dispatcher.MessageSource.STREAM, + ), + msg + ) Conversation.assert_not_called() self.listener.on_conversation_new.assert_not_called() @@ -385,7 +479,15 @@ def test_no_autocreate_conversation_from_other_with_body(self): "aioxmpp.im.p2p.Conversation", )) - self.assertIs(self.s._filter_inbound_message(msg), msg) + self.assertIs( + self.s._filter_message( + msg, + msg.from_, + False, + im_dispatcher.MessageSource.STREAM, + ), + msg + ) Conversation.assert_not_called() self.listener.on_conversation_new.assert_not_called() @@ -400,7 +502,15 @@ def test_no_autocreate_conversation_from_normal_without_body(self): "aioxmpp.im.p2p.Conversation", )) - self.assertIs(self.s._filter_inbound_message(msg), msg) + self.assertIs( + self.s._filter_message( + msg, + msg.from_, + False, + im_dispatcher.MessageSource.STREAM, + ), + msg + ) Conversation.assert_not_called() self.listener.on_conversation_new.assert_not_called() @@ -415,7 +525,15 @@ def test_no_autocreate_conversation_from_chat_without_body(self): "aioxmpp.im.p2p.Conversation", )) - self.assertIs(self.s._filter_inbound_message(msg), msg) + self.assertIs( + self.s._filter_message( + msg, + msg.from_, + False, + im_dispatcher.MessageSource.STREAM, + ), + msg + ) Conversation.assert_not_called() self.listener.on_conversation_new.assert_not_called() @@ -483,51 +601,3 @@ def swevset(*args): self.assertEqual(len(fwmsgs), 1) self.assertEqual(fwmsgs[0].body[None], "bar") self.assertEqual(len(swmsgs), 1) - - # @blocking_timed - # @asyncio.coroutine - # def test_autocreate_conversation(self): - # svc1 = self.firstwitch.summon(p2p.Service) - - # c1 = None - - # def new_conversation(conv): - # nonlocal c1 - # c1 = conv - # c1.on_message_received.connect(fwmsgs.append) - # c1.on_message_received.connect(fwevset) - - # svc1.on_conversation_new.connect(new_conversation) - - # c2 = yield from self.secondwitch.summon(p2p.Service).get_conversation( - # self.firstwitch.local_jid.bare() - # ) - - # fwmsgs = [] - # fwev = asyncio.Event() - - # def fwevset(*args): - # fwev.set() - - # swmsgs = [] - # swev = asyncio.Event() - - # def swevset(*args): - # swev.set() - - # c2.on_message_received.connect(swmsgs.append) - # c2.on_message_received.connect(swevset) - - # msg = aioxmpp.Message(aioxmpp.MessageType.CHAT) - # msg.body[None] = "foo" - # yield from c2.send_message(msg) - # yield from fwev.wait() - - # self.assertIsNotNone(c1) - # self.assertIs( - # c1, - # (yield from svc1.get_conversation(self.secondwitch.local_jid)), - # ) - - # self.assertEqual(len(fwmsgs), 1) - # self.assertEqual(fwmsgs[0].body[None], "foo") From 8e6c0351b24d2e60d230f60687df7681186a9898 Mon Sep 17 00:00:00 2001 From: Jonas Wielicki Date: Sat, 1 Apr 2017 19:36:28 +0200 Subject: [PATCH 10/40] im: Support for Message Carbons --- aioxmpp/callbacks.py | 1 + aioxmpp/im/conversation.py | 3 +- aioxmpp/im/dispatcher.py | 35 ++++++++ aioxmpp/im/p2p.py | 11 ++- tests/im/test_dispatcher.py | 161 +++++++++++++++++++++++++++++++++++- tests/im/test_p2p.py | 12 ++- 6 files changed, 214 insertions(+), 9 deletions(-) diff --git a/aioxmpp/callbacks.py b/aioxmpp/callbacks.py index 3dde37b3..49dc19dd 100644 --- a/aioxmpp/callbacks.py +++ b/aioxmpp/callbacks.py @@ -568,6 +568,7 @@ def connect(self, coro): :meth:`connect` returns a token which can be used with :meth:`disconnect` to disconnect the coroutine. """ + self.logger.debug("connecting %r", coro) return self._connect(coro) def context_connect(self, coro): diff --git a/aioxmpp/im/conversation.py b/aioxmpp/im/conversation.py index ac0a97c4..b8aa8350 100644 --- a/aioxmpp/im/conversation.py +++ b/aioxmpp/im/conversation.py @@ -217,8 +217,7 @@ class AbstractConversation(metaclass=abc.ABCMeta): """ - on_message_received = aioxmpp.callbacks.Signal() - on_message_sent = aioxmpp.callbacks.Signal() + on_message = aioxmpp.callbacks.Signal() on_state_changed = aioxmpp.callbacks.Signal() on_exit = aioxmpp.callbacks.Signal() diff --git a/aioxmpp/im/dispatcher.py b/aioxmpp/im/dispatcher.py index 88755d80..c5413618 100644 --- a/aioxmpp/im/dispatcher.py +++ b/aioxmpp/im/dispatcher.py @@ -19,9 +19,11 @@ # . # ######################################################################## +import asyncio import enum import aioxmpp.callbacks +import aioxmpp.carbons import aioxmpp.service import aioxmpp.stream @@ -69,6 +71,8 @@ class IMDispatcher(aioxmpp.service.Service): # this helps one-to-one conversations a lot, because they can simply # re-use the PresenceClient state aioxmpp.dispatcher.SimplePresenceDispatcher, + + aioxmpp.carbons.CarbonsClient, ] def __init__(self, client, **kwargs): @@ -76,12 +80,43 @@ def __init__(self, client, **kwargs): self.message_filter = aioxmpp.callbacks.Filter() self.presence_filter = aioxmpp.callbacks.Filter() + @aioxmpp.service.depsignal( + aioxmpp.node.Client, + "before_stream_established") + @asyncio.coroutine + def enable_carbons(self, *args): + carbons = self.dependencies[aioxmpp.carbons.CarbonsClient] + try: + yield from carbons.enable() + except (RuntimeError, aioxmpp.errors.XMPPError): + self.logger.info( + "remote server does not support message carbons" + ) + else: + self.logger.info( + "message carbons enabled successfully" + ) + @aioxmpp.service.depsignal( aioxmpp.stream.StanzaStream, "on_message_received") def dispatch_message(self, message, *, sent=False, source=MessageSource.STREAM): + if message.xep0280_received is not None: + if (message.from_ is not None and + message.from_ != self.client.local_jid.bare()): + return + message = message.xep0280_received.stanza + source = MessageSource.CARBONS + elif message.xep0280_sent is not None: + if (message.from_ is not None and + message.from_ != self.client.local_jid.bare()): + return + message = message.xep0280_sent.stanza + sent = True + source = MessageSource.CARBONS + peer = message.to if sent else message.from_ filtered = self.message_filter.filter( diff --git a/aioxmpp/im/p2p.py b/aioxmpp/im/p2p.py index 6287e56b..9da9d9c3 100644 --- a/aioxmpp/im/p2p.py +++ b/aioxmpp/im/p2p.py @@ -30,7 +30,7 @@ AbstractConversationService, ) -from .dispatcher import IMDispatcher +from .dispatcher import IMDispatcher, MessageSource from .service import ConversationService @@ -54,7 +54,12 @@ def __init__(self, service, peer_jid, parent=None): ) def _handle_message(self, msg, peer, sent, source): - self.on_message_received(msg) + if sent: + member = self.__members[0] + else: + member = self.__members[1] + + self.on_message(msg, member, source) @property def peer_jid(self): @@ -71,7 +76,7 @@ def me(self): @asyncio.coroutine def send_message(self, msg): msg.to = self.__peer_jid - self.on_message_sent(msg) + self.on_message(msg, self.me, MessageSource.STREAM) yield from self._client.stream.send(msg) @asyncio.coroutine diff --git a/tests/im/test_dispatcher.py b/tests/im/test_dispatcher.py index eabeb1d4..7e6d37d7 100644 --- a/tests/im/test_dispatcher.py +++ b/tests/im/test_dispatcher.py @@ -22,13 +22,18 @@ import unittest import aioxmpp.callbacks +import aioxmpp.carbons.xso as carbons_xso import aioxmpp.muc import aioxmpp.im.dispatcher as dispatcher import aioxmpp.service import aioxmpp.stream +from aioxmpp.utils import namespaces + from aioxmpp.testutils import ( make_connected_client, + CoroutineMock, + run_coroutine, ) @@ -39,7 +44,14 @@ class TestIMDispatcher(unittest.TestCase): def setUp(self): self.cc = make_connected_client() - self.s = dispatcher.IMDispatcher(self.cc) + self.cc.local_jid = TEST_LOCAL.replace(resource="we") + self.disco_client = aioxmpp.DiscoClient(self.cc) + self.carbons = aioxmpp.CarbonsClient(self.cc, dependencies={ + aioxmpp.DiscoClient: self.disco_client, + }) + self.s = dispatcher.IMDispatcher(self.cc, dependencies={ + aioxmpp.CarbonsClient: self.carbons, + }) self.listener = unittest.mock.Mock() for filter_ in ["message_filter", "presence_filter"]: @@ -70,6 +82,21 @@ def test_orders_after_simple_presence_dispatcher(self): dispatcher.IMDispatcher.ORDER_AFTER, ) + def test_depends_on_carbons(self): + self.assertIn( + aioxmpp.CarbonsClient, + dispatcher.IMDispatcher.ORDER_AFTER, + ) + + def test_dispatcher_connects_to_before_stream_established(self): + self.assertTrue( + aioxmpp.service.is_depsignal_handler( + aioxmpp.Client, + "before_stream_established", + dispatcher.IMDispatcher.enable_carbons, + ) + ) + def test_dispatch_message_listens_to_on_message_received(self): self.assertTrue( aioxmpp.service.is_depsignal_handler( @@ -114,7 +141,6 @@ def test_dispatch_presences(self): False, ) - def test_dispatch_simple_messages(self): msg = aioxmpp.Message( type_=aioxmpp.MessageType.CHAT, @@ -146,3 +172,134 @@ def test_dispatch_sent_messages(self): True, dispatcher.MessageSource.STREAM, ) + + def test_dispatch_unpacks_received_carbon(self): + msg = aioxmpp.Message( + type_=aioxmpp.MessageType.CHAT, + from_=TEST_PEER, + to=TEST_LOCAL, + ) + + wrapper = aioxmpp.Message( + type_=msg.type_, + from_=TEST_LOCAL.bare(), + to=TEST_LOCAL, + ) + wrapper.xep0280_received = carbons_xso.Received() + wrapper.xep0280_received.stanza = msg + + self.s.dispatch_message(wrapper) + + self.listener.message_filter.assert_called_once_with( + msg, + TEST_PEER, + False, + dispatcher.MessageSource.CARBONS, + ) + + def test_dispatch_drops_received_carbon_with_incorrect_from(self): + msg = aioxmpp.Message( + type_=aioxmpp.MessageType.CHAT, + from_=TEST_LOCAL, + to=TEST_PEER, + ) + + wrapper = aioxmpp.Message( + type_=msg.type_, + from_=TEST_PEER, + to=TEST_LOCAL, + ) + wrapper.xep0280_received = carbons_xso.Received() + wrapper.xep0280_received.stanza = msg + + self.s.dispatch_message(wrapper) + + self.listener.message_filter.assert_not_called() + + def test_dispatch_unpacks_sent_carbon(self): + msg = aioxmpp.Message( + type_=aioxmpp.MessageType.CHAT, + from_=TEST_LOCAL.replace(resource="other"), + to=TEST_PEER, + ) + + wrapper = aioxmpp.Message( + type_=msg.type_, + from_=TEST_LOCAL.bare(), + to=TEST_LOCAL, + ) + wrapper.xep0280_sent = carbons_xso.Sent() + wrapper.xep0280_sent.stanza = msg + + self.s.dispatch_message(wrapper) + + self.listener.message_filter.assert_called_once_with( + msg, + TEST_PEER, + True, + dispatcher.MessageSource.CARBONS, + ) + + def test_dispatch_drops_sent_carbon_with_incorrect_from(self): + msg = aioxmpp.Message( + type_=aioxmpp.MessageType.CHAT, + from_=TEST_LOCAL, + to=TEST_PEER, + ) + + wrapper = aioxmpp.Message( + type_=msg.type_, + from_=TEST_PEER, + to=TEST_LOCAL, + ) + wrapper.xep0280_sent = carbons_xso.Sent() + wrapper.xep0280_sent.stanza = msg + + self.s.dispatch_message(wrapper) + + self.listener.message_filter.assert_not_called() + + def test_enable_carbons_enables_carbons(self): + with unittest.mock.patch.object( + self.carbons, + "enable", + new=CoroutineMock()) as enable: + run_coroutine(self.s.enable_carbons()) + + enable.assert_called_once_with() + + def test_enable_carbons_does_not_swallow_random_exception(self): + class FooException(Exception): + pass + + with unittest.mock.patch.object( + self.carbons, + "enable", + new=CoroutineMock()) as enable: + enable.side_effect = FooException() + with self.assertRaises(FooException): + run_coroutine(self.s.enable_carbons()) + + enable.assert_called_once_with() + + def test_enable_carbons_ignores_RuntimeError_from_enable(self): + with unittest.mock.patch.object( + self.carbons, + "enable", + new=CoroutineMock()) as enable: + enable.side_effect = RuntimeError() + run_coroutine(self.s.enable_carbons()) + + enable.assert_called_once_with() + + def test_enable_carbons_ignores_XMPPError_from_enable(self): + with unittest.mock.patch.object( + self.carbons, + "enable", + new=CoroutineMock()) as enable: + enable.side_effect = aioxmpp.errors.XMPPError( + (namespaces.stanzas, "foo") + ) + run_coroutine(self.s.enable_carbons()) + + enable.assert_called_once_with() diff --git a/tests/im/test_p2p.py b/tests/im/test_p2p.py index 1a1f090f..d179e769 100644 --- a/tests/im/test_p2p.py +++ b/tests/im/test_p2p.py @@ -58,7 +58,7 @@ def setUp(self): self.c = p2p.Conversation(self.svc, PEER_JID) - for ev in ["on_message_received"]: + for ev in ["on_message"]: listener = getattr(self.listener, ev) signal = getattr(self.c, ev) listener.return_value = None @@ -101,6 +101,12 @@ def test_send_message_stamps_to_and_sends(self): self.cc.stream.send.assert_called_once_with(msg) self.assertEqual(msg.to, PEER_JID) + self.listener.on_message.assert_called_once_with( + msg, + self.c.me, + im_dispatcher.MessageSource.STREAM, + ) + def test_inbound_message_dispatched_to_event(self): msg = unittest.mock.sentinel.message self.c._handle_message( @@ -109,8 +115,10 @@ def test_inbound_message_dispatched_to_event(self): False, im_dispatcher.MessageSource.STREAM ) - self.listener.on_message_received.assert_called_once_with( + self.listener.on_message.assert_called_once_with( msg, + self.c.members[1], + im_dispatcher.MessageSource.STREAM, ) def test_leave_calls_conversation_left(self): From 05105a2a004d2b7a40eb9ab66b17b01ef6e947d0 Mon Sep 17 00:00:00 2001 From: Jonas Wielicki Date: Sun, 2 Apr 2017 15:23:07 +0200 Subject: [PATCH 11/40] im: Develop AbstractConversation API --- aioxmpp/im/__init__.py | 3 + aioxmpp/im/conversation.py | 380 ++++++++++++++++++++++++++++++++++++- 2 files changed, 373 insertions(+), 10 deletions(-) diff --git a/aioxmpp/im/__init__.py b/aioxmpp/im/__init__.py index ef366997..d3b6c7fc 100644 --- a/aioxmpp/im/__init__.py +++ b/aioxmpp/im/__init__.py @@ -46,6 +46,8 @@ .. autoclass:: ConversationState +.. autoclass:: ConversationFeature + .. autoclass:: InviteMode Abstract base classes @@ -66,5 +68,6 @@ from .conversation import ( # NOQA ConversationState, + ConversationFeature, InviteMode, ) diff --git a/aioxmpp/im/conversation.py b/aioxmpp/im/conversation.py index b8aa8350..fb8a08f8 100644 --- a/aioxmpp/im/conversation.py +++ b/aioxmpp/im/conversation.py @@ -27,10 +27,117 @@ class InviteMode(enum.Enum): + """ + Represent different possible modes for sending an invitation. + + .. attribute:: DIRECT + + The invitation is sent directly to the invitee, without going through a + service specific to the conversation. + + .. attribute:: MEDIATED + + The invitation is sent indirectly through a service which is providing + the conversation. Advantages of using this mode include most notably + that the service can automatically add the invitee to the list of + allowed participants in configurations where such restrictions exist (or + deny the request if the inviter does not have the permissions to do so). + """ + DIRECT = 0 MEDIATED = 1 +class ConversationFeature(enum.Enum): + """ + Represent individual features of a :term:`Conversation` of a + :term:`Conversation Implementation`. + + .. seealso:: + + The :class:`.AbstractConversation.features` provides a set of features + offered by a specific :term:`Conversation`. + + .. attribute:: BAN + + Allows use of :meth:`~.AbstractConversation.ban`. + + .. attribute:: BAN_WITH_KICK + + Explicit support for setting the `request_kick` argument to :data:`True` + in :meth:`~.AbstractConversation.ban`. + + .. attribute:: INVITE + + Allows use of :meth:`~.AbstractConversation.invite`. + + .. attribute:: INVITE_DIRECT + + Explicit support for the :attr:`~.InviteMode.DIRECT` invite mode when + calling :meth:`~.AbstractConversation.invite`. + + .. attribute:: INVITE_DIRECT_CONFIGURE + + Explicit support for configuring the conversation to allow the invitee + to join when using :attr:`~.InviteMode.DIRECT` with + :meth:`~.AbstractConversation.invite`. + + .. attribute:: INVITE_MEDIATED + + Explicit support for the :attr:`~.InviteMode.MEDIATED` invite mode when + calling :meth:`~.AbstractConversation.invite`. + + .. attribute:: INVITE_UPGRADE + + Explicit support and requirement for `allow_upgrade` when + calling :meth:`~.AbstractConversation.invite`. + + .. attribute:: KICK + + Allows use of :meth:`~.AbstractConversation.kick`. + + .. attribute:: LEAVE + + Allows use of :meth:`~.AbstractConversation.leave`. + + .. attribute:: SEND_MESSAGE + + Allows use of :meth:`~.AbstractConversation.send_message`. + + .. attribute:: SEND_MESSAGE_TRACKED + + Allows use of :meth:`~.AbstractConversation.send_message_tracked`. + + .. attribute:: SET_NICK + + Allows use of :meth:`~.AbstractConversation.set_nick`. + + .. attribute:: SET_NICK_OF_OTHERS + + Explicit support for changing the nickname of other members when calling + :meth:`~.AbstractConversation.set_nick`. + + .. attribute:: SET_TOPIC + + Allows use of :meth:`~.AbstractConversation.set_topic`. + + """ + + BAN = 'ban' + BAN_WITH_KICK = 'ban-with-kick' + INVITE = 'invite' + INVITE_DIRECT = 'invite-direct' + INVITE_DIRECT_CONFIGURE = 'invite-direct-configure' + INVITE_MEDIATED = 'invite-mediated' + INVITE_UPGRADE = 'invite-upgrade' + KICK = 'kick' + SEND_MESSAGE = 'send-message' + SEND_MESSAGE_TRACKED = 'send-message-tracked' + SET_TOPIC = 'set-topic' + SET_NICK = 'set-nick' + SET_NICK_OF_OTHERS = 'set-nick-of-others' + + class ConversationState(enum.Enum): """ State of a conversation. @@ -120,6 +227,10 @@ def direct_jid(self): @property def conversation_jid(self): + """ + The :class:`~aioxmpp.JID` of the conversation member relative to the + conversation. + """ return self._conversation_jid @property @@ -131,9 +242,32 @@ class AbstractConversation(metaclass=abc.ABCMeta): """ Interface for a conversation. + .. note:: + + All signals may receive additional keyword arguments depending on the + specific subclass implementing them. Handlers connected to the signals + **must** support arbitrary keyword arguments. + + To support future extensions to the base specification, subclasses must + prefix all keyword argument names with a common, short prefix which ends + with an underscore. For example, a MUC implementation could use + ``muc_presence``. + + Future extensions to the base class will use either names without + underscores or the ``base_`` prefix. + + .. note:: + + In the same spirit, methods defined on subclasses should use the same + prefix. However, the base class does not guarantee that it won’t use + names with underscores in future extensions. + + To prevent collisions, subclasses should avoid the use of prefixes which + are verbs in the english language. + Signals: - .. signal:: on_message(msg, member, source) + .. signal:: on_message(msg, member, source, **kwargs) A message occured in the conversation. @@ -172,7 +306,7 @@ class AbstractConversation(metaclass=abc.ABCMeta): may include messages not understood and/or which carry no textual payload. - .. signal:: on_state_changed(member, new_state, msg) + .. signal:: on_state_changed(member, new_state, msg, **kwargs) The conversation state of a member has changed. @@ -187,28 +321,114 @@ class AbstractConversation(metaclass=abc.ABCMeta): exact point at which this signal fires for the local occupant is determined by the implementation. + .. signal:: on_presence_changed(member, resource, presence, **kwargs) + + The presence state of a member has changed. + + :param member: The member object of the affected member. + :type member: :class:`~.AbstractConversationMember` + :param resource: The resource of the member which changed presence. + :type resource: :class:`str` or :data:`None` + :param presence: The presence stanza + :type presence: :class:`aioxmpp.Presence` + + If the `presence` stanza affects multiple resources, `resource` holds + the affected resource and the event is emmited once per affected + resource. + + However, the `presence` stanza affects only a single resource, + `resource` is :data:`None`; the affected resource can be extracted from + the :attr:`~.StanzaBase.from_` of the `presence` stanza in that case. + This is to help implementations to know whether a bunch of resources was + shot offline by a single presence (`resource` is not :data:`None`), e.g. + due to an error or whether a single resource went offline by itself. + Implementations may want to only show the former case. + + .. note:: + + In some implementations, unavailable presence implies that a + participant leaves the room, in which case :meth:`on_leave` is also + emitted. + + .. signal:: on_nick_changed(member, old_nick, new_nick, **kwargs) + + The nickname of a member has changed + + :param member: The member object of the member whose nick has changed. + :type member: :class:`~.AbstractConversationMember` + :param old_nick: The old nickname of the member. + :type old_nick: :class:`str` or :data:`None` + :param new_nick: The new nickname of the member. + :type new_nick: :class:`str` + + The new nickname is already set in the `member` object, if the `member` + object has an accessor for the nickname. + + In some cases, `old_nick` may be :data:`None`. These cases include those + where it is not trivial for the protocol to actually determine the old + nickname or where no nickname was set before. + + .. signal:: on_join(member, **kwargs) + + A new member has joined the conversation. + + :param member: The member object of the new member. + :type member: :class:`~.AbstractConversationMember` + + When this signal is called, the `member` is already included in the + :attr:`members`. + + .. signal:: on_leave(member, **kwargs) + + A member has left the conversation. + + :param member: The member object of the previous member. + :type member: :class:`~.AbstractConversationMember` + + When this signal is called, the `member` has already been removed from + the :attr:`members`. + + .. signal:: on_exit(**kwargs) + + The local user has left the conversation. + + When this signal fires, the conversation is defunct in the sense that it + cannot be used to send messages anymore. A new conversation needs to be + started. + Properties: + .. autoattribute:: features + + .. autoattribute:: jid + .. autoattribute:: members .. autoattribute:: me Methods: - .. automethod:: send_message - - .. automethod:: send_message_tracked + .. note:: - .. automethod:: kick + See :attr:`features` for discovery of support for individual methods at + a given conversation instance. .. automethod:: ban .. automethod:: invite - .. automethod:: set_topic + .. automethod:: kick .. automethod:: leave + .. automethod:: send_message + + .. automethod:: send_message_tracked + + .. automethod:: set_nick + + .. automethod:: set_topic + Interface solely for subclasses: .. attribute:: _client @@ -219,6 +439,9 @@ class AbstractConversation(metaclass=abc.ABCMeta): on_message = aioxmpp.callbacks.Signal() on_state_changed = aioxmpp.callbacks.Signal() + on_presence_changed = aioxmpp.callbacks.Signal() + on_join = aioxmpp.callbacks.Signal() + on_leave = aioxmpp.callbacks.Signal() on_exit = aioxmpp.callbacks.Signal() def __init__(self, service, parent=None, **kwargs): @@ -253,6 +476,41 @@ def me(self): The member representing the local member. """ + @abc.abstractproperty + def jid(self): + """ + The address of the conversation. + """ + + @property + def features(self): + """ + A set of features supported by this :term:`Conversation`. + + The members of the set are usually drawn from the + :class:`~.ConversationFeature` :mod:`enumeration `; + :term:`Conversation Implementations ` are + free to add custom elements from other enumerations to this set. + + Unless stated otherwise, the methods of :class:`~.AbstractConversation` + and its subclasses always may throw one of the following exceptions, + **unless** support for those methods is explicitly stated with an + appropriate :class:`~.ConversationFeature` member in the + :attr:`features`. + + * :class:`NotImplementedError` if the :term:`Conversation + Implementation` does not support the method at all. + * :class:`RuntimeError` if the server does not support the method. + * :class:`aioxmpp.XMPPCancelError` with ``feature-not-implemented`` + condition. + + *If* support for the method is claimed in :attr:`features`, these + exceptions **must not** be raised (for the given reason; of course, a + method may still raise an :class:`aioxmpp.XMPPCancelError` due for + other conditions such as ``item-not-found``). + """ + return set() + @asyncio.coroutine def send_message(self, body): """ @@ -266,6 +524,13 @@ def send_message(self, body): Subclasses may override this method with a more specialised implementation. Subclasses which do not provide tracked message sending **must** override this method to provide untracked message sending. + + .. seealso:: + + The corresponding feature is + :attr:`.ConversationFeature.SEND_MESSAGE`. See :attr:`features` for + details. + """ tracker = yield from self.send_message_tracked(body) tracker.cancel() @@ -300,22 +565,51 @@ def send_message_tracked(self, body, *, timeout=None): application at some point to prevent degration of performance and running out of memory. + .. seealso:: + + The corresponding feature is + :attr:`.ConversationFeature.SEND_MESSAGE_TRACKED`. See + :attr:`features` for details. + """ @asyncio.coroutine def kick(self, member): """ Kick a member from a conversation. + + :param member: The member to kick. + :raises aioxmpp.errors.XMPPError: if the server returned an error for + the kick command. + + .. seealso:: + + The corresponding feature is + :attr:`.ConversationFeature.KICK`. See :attr:`features` for details. """ - raise self._not_implemented_error("kicking occupants") + raise self._not_implemented_error("kicking members") @asyncio.coroutine def ban(self, member, *, request_kick=True): """ Ban a member from re-joining a conversation. - If `request_kick` is :data:`True`, it is ensured that the member is - kicked from the conversation, too. + If `request_kick` is true, the implementation attempts to kick the + member from the conversation, too, if that does not happen + automatically. There is no guarantee that the member is not removed + from the conversation even if `request_kick` is false. + + Additional features: + + :attr:`~.ConversationFeature.BAN_WITH_KICK` + If `request_kick` is true, the member is kicked from the + conversation. + + .. seealso:: + + The corresponding feature for this method is + :attr:`.ConversationFeature.BAN`. See :attr:`features` for details + on the semantics of features. """ raise self._not_implemented_error("banning members") @@ -333,13 +627,73 @@ def invite(self, jid, *, If `allow_upgrade` is false and a new conversation would be needed to invite an entity, :class:`ValueError` is raised. + + Additional features: + + :attr:`~.ConversationFeature.INVITE_DIRECT` + If `preferred_mode` is :attr:`~.im.InviteMode.DIRECT`, a direct + invitation will be used. + + :attr:`~.ConversationFeature.INVITE_DIRECT_CONFIGURE` + If a direct invitation is used, the conversation will be configured + to allow the invitee to join before the invitation is sent. This may + fail with a :class:`aioxmpp.errors.XMPPError`, in which case the + error is re-raised and the invitation not sent. + + :attr:`~.ConversationFeature.INVITE_MEDIATED` + If `preferred_mode` is :attr:`~.im.InviteMode.MEDIATED`, a mediated + invitation will be used. + + :attr:`~.ConversationFeature.INVITE_UPGRADE` + If `allow_upgrade` is :data:`True`, an upgrade will be performed and + a new conversation is returned. If `allow_upgrade` is :data:`False`, + the invite will fail. + + .. seealso:: + + The corresponding feature for this method is + :attr:`.ConversationFeature.INVITE`. See :attr:`features` for details + on the semantics of features. """ raise self._not_implemented_error("inviting entities") + @asyncio.coroutine + def set_nick(self, new_nickname): + """ + Change our nickname. + + :param new_nickname: The new nickname for the member. + :type new_nickname: :class:`str` + :raises ValueError: if the nickname is not a valid nickname + + Sends the request to change the nickname and waits for the request to + be sent. + + There is no guarantee that the nickname change will actually be + applied; listen to the :meth:`on_nick_changed` event. + + Implementations may provide a different method which provides more + feedback. + + .. seealso:: + + The corresponding feature for this method is + :attr:`.ConversationFeature.SET_NICK`. See :attr:`features` for + details on the semantics of features. + + """ + @asyncio.coroutine def set_topic(self, new_topic): """ Change the (possibly publicly) visible topic of the conversation. + + .. seealso:: + + The corresponding feature is + :attr:`.ConversationFeature.SET_TOPIC`. See :attr:`features` for + details. + """ raise self._not_implemented_error("changing the topic") @@ -352,6 +706,12 @@ def leave(self): The base implementation calls :meth:`.AbstractConversationService._conversation_left` and must be called after all other preconditions for a leave have completed. + + .. seealso:: + + The corresponding feature is + :attr:`.ConversationFeature.LEAVE`. See :attr:`features` for + details. """ self._service._conversation_left(self) From a2324738282feb5fca80f175497f404597eb30a4 Mon Sep 17 00:00:00 2001 From: Jonas Wielicki Date: Sun, 2 Apr 2017 17:24:42 +0200 Subject: [PATCH 12/40] im-muc: Port MUCClient signal handlers to @depsignal --- aioxmpp/muc/service.py | 13 ++----------- tests/muc/test_service.py | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/aioxmpp/muc/service.py b/aioxmpp/muc/service.py index 9b864ff7..1ef4beb7 100644 --- a/aioxmpp/muc/service.py +++ b/aioxmpp/muc/service.py @@ -994,17 +994,6 @@ class MUCClient(aioxmpp.service.Service): def __init__(self, client, **kwargs): super().__init__(client, **kwargs) - self._signal_tokens = [ - _connect_to_signal( - client.on_stream_established, - self._stream_established - ), - _connect_to_signal( - client.on_stream_destroyed, - self._stream_destroyed - ) - ] - self._pending_mucs = {} self._joined_mucs = {} @@ -1016,6 +1005,7 @@ def _send_join_presence(self, mucjid, history, nick, password): presence.xep0045_muc.history = history self.client.stream.enqueue(presence) + @aioxmpp.service.depsignal(aioxmpp.Client, "on_stream_established") def _stream_established(self): self.logger.debug("stream established, (re-)connecting to %d mucs", len(self._pending_mucs)) @@ -1027,6 +1017,7 @@ def _stream_established(self): self.logger.debug("%s: sending join presence", muc.mucjid) self._send_join_presence(muc.mucjid, history, nick, muc.password) + @aioxmpp.service.depsignal(aioxmpp.Client, "on_stream_destroyed") def _stream_destroyed(self): self.logger.debug( "stream destroyed, preparing autorejoin and cleaning up the others" diff --git a/tests/muc/test_service.py b/tests/muc/test_service.py index d659ca73..4aa9a1a6 100644 --- a/tests/muc/test_service.py +++ b/tests/muc/test_service.py @@ -2276,6 +2276,24 @@ def test_inbound_presence_filter_is_decorated(self): ) ) + def test__stream_established_is_decorated(self): + self.assertTrue( + aioxmpp.service.is_depsignal_handler( + aioxmpp.Client, + "on_stream_established", + muc_service.MUCClient._stream_established, + ) + ) + + def test__stream_destroyed_is_decorated(self): + self.assertTrue( + aioxmpp.service.is_depsignal_handler( + aioxmpp.Client, + "on_stream_destroyed", + muc_service.MUCClient._stream_destroyed, + ) + ) + def test__inbound_presence_filter_passes_ordinary_presence(self): presence = aioxmpp.stanza.Presence() self.assertIs( From 856d1c9a26865251dd96fc510a126f9f694cfe32 Mon Sep 17 00:00:00 2001 From: Jonas Wielicki Date: Mon, 3 Apr 2017 18:33:07 +0200 Subject: [PATCH 13/40] im:muc: Port MUCClient._inbound_presence_filter to IMDisptacher --- aioxmpp/muc/service.py | 15 +++- tests/muc/test_service.py | 155 +++++++++++++++++++++++++++++++------- 2 files changed, 142 insertions(+), 28 deletions(-) diff --git a/aioxmpp/muc/service.py b/aioxmpp/muc/service.py index 1ef4beb7..3846e153 100644 --- a/aioxmpp/muc/service.py +++ b/aioxmpp/muc/service.py @@ -31,6 +31,7 @@ import aioxmpp.stanza import aioxmpp.structs import aioxmpp.tracking +import aioxmpp.im.dispatcher from . import xso as muc_xso @@ -989,6 +990,11 @@ class MUCClient(aioxmpp.service.Service): 1.0. """ + + ORDER_AFTER = [ + aioxmpp.im.dispatcher.IMDispatcher, + ] + on_muc_joined = aioxmpp.callbacks.Signal() def __init__(self, client, **kwargs): @@ -1110,8 +1116,13 @@ def _inbound_muc_presence(self, stanza): else: fut.set_exception(stanza.error.to_exception()) - @aioxmpp.service.inbound_presence_filter - def _inbound_presence_filter(self, stanza): + @aioxmpp.service.depfilter( + aioxmpp.im.dispatcher.IMDispatcher, + "presence_filter") + def _handle_presence(self, stanza, peer, sent): + if sent: + return stanza + if stanza.xep0045_muc_user is not None: self._inbound_muc_user_presence(stanza) return None diff --git a/tests/muc/test_service.py b/tests/muc/test_service.py index 4aa9a1a6..6f5d2aaf 100644 --- a/tests/muc/test_service.py +++ b/tests/muc/test_service.py @@ -29,6 +29,7 @@ import aioxmpp.callbacks import aioxmpp.errors import aioxmpp.forms +import aioxmpp.im.dispatcher as im_dispatcher import aioxmpp.muc.service as muc_service import aioxmpp.muc.xso as muc_xso import aioxmpp.service as service @@ -2261,7 +2262,10 @@ def test_is_service(self): def setUp(self): self.cc = make_connected_client() - self.s = muc_service.MUCClient(self.cc) + self.im_dispatcher = im_dispatcher.IMDispatcher(self.cc) + self.s = muc_service.MUCClient(self.cc, dependencies={ + im_dispatcher.IMDispatcher: self.im_dispatcher, + }) def test_event_attributes(self): self.assertIsInstance( @@ -2269,10 +2273,18 @@ def test_event_attributes(self): aioxmpp.callbacks.AdHocSignal ) - def test_inbound_presence_filter_is_decorated(self): + def test_depends_on_IMDispatcher(self): + self.assertIn( + im_dispatcher.IMDispatcher, + muc_service.MUCClient.ORDER_AFTER, + ) + + def test_handle_presence_is_decorated(self): self.assertTrue( - aioxmpp.service.is_inbound_presence_filter( - muc_service.MUCClient._inbound_presence_filter, + aioxmpp.service.is_depfilter_handler( + im_dispatcher.IMDispatcher, + "presence_filter", + muc_service.MUCClient._handle_presence, ) ) @@ -2294,14 +2306,16 @@ def test__stream_destroyed_is_decorated(self): ) ) - def test__inbound_presence_filter_passes_ordinary_presence(self): + def test__handle_presence_passes_ordinary_presence(self): presence = aioxmpp.stanza.Presence() self.assertIs( presence, - self.s._inbound_presence_filter(presence) + self.s._handle_presence( + presence, presence.from_, False + ) ) - def test__inbound_presence_filter_catches_presence_with_muc_user(self): + def test__handle_presence_catches_presence_with_muc_user(self): presence = aioxmpp.stanza.Presence() presence.xep0045_muc_user = muc_xso.UserExt() @@ -2310,12 +2324,35 @@ def test__inbound_presence_filter_catches_presence_with_muc_user(self): "_inbound_muc_user_presence") as handler: handler.return_value = 123 self.assertIsNone( - self.s._inbound_presence_filter(presence) + self.s._handle_presence( + presence, + presence.from_, + False, + ) ) handler.assert_called_with(presence) - def test__inbound_presence_filter_catches_presence_with_muc(self): + def test__handle_presence_ignores_presence_with_muc_user_if_sent(self): + presence = aioxmpp.stanza.Presence() + presence.xep0045_muc_user = muc_xso.UserExt() + + with unittest.mock.patch.object( + self.s, + "_inbound_muc_user_presence") as handler: + handler.return_value = 123 + self.assertIs( + presence, + self.s._handle_presence( + presence, + presence.from_, + True, + ) + ) + + handler.assert_not_called() + + def test__handle_presence_catches_presence_with_muc(self): presence = aioxmpp.stanza.Presence() presence.xep0045_muc = muc_xso.GenericExt() with unittest.mock.patch.object( @@ -2323,11 +2360,33 @@ def test__inbound_presence_filter_catches_presence_with_muc(self): "_inbound_muc_presence") as handler: handler.return_value = 123 self.assertIsNone( - self.s._inbound_presence_filter(presence) + self.s._handle_presence( + presence, + presence.from_, + False, + ) ) handler.assert_called_with(presence) + def test__handle_presence_ignores_presence_with_muc_if_sent(self): + presence = aioxmpp.stanza.Presence() + presence.xep0045_muc = muc_xso.GenericExt() + with unittest.mock.patch.object( + self.s, + "_inbound_muc_presence") as handler: + handler.return_value = 123 + self.assertIs( + presence, + self.s._handle_presence( + presence, + presence.from_, + True, + ) + ) + + handler.assert_not_called() + def test_join_without_password_or_history(self): with self.assertRaises(KeyError): self.s.get_muc(TEST_MUC_JID) @@ -2541,7 +2600,11 @@ def test_future_receives_exception_on_join_error(self): type_=aioxmpp.structs.PresenceType.ERROR) response.xep0045_muc = muc_xso.GenericExt() response.error = aioxmpp.stanza.Error() - self.s._inbound_presence_filter(response) + self.s._handle_presence( + response, + response.from_, + False, + ) self.assertTrue(future.done()) self.assertIsInstance( @@ -2589,9 +2652,11 @@ def test_join_completed_on_self_presence(self): status_codes={110}, ) - base = unittest.mock.Mock() - - self.s._inbound_presence_filter(occupant_presence) + self.s._handle_presence( + occupant_presence, + occupant_presence.from_, + False, + ) self.assertTrue(future.done()) self.assertIsNone(future.result()) @@ -2609,9 +2674,11 @@ def test_join_not_completed_on_occupant_presence(self): ) occupant_presence.xep0045_muc_user = muc_xso.UserExt() - base = unittest.mock.Mock() - - self.s._inbound_presence_filter(occupant_presence) + self.s._handle_presence( + occupant_presence, + occupant_presence.from_, + False, + ) self.assertFalse(future.done()) @@ -2649,7 +2716,11 @@ def mkpresence(nick): )) for presence in occupant_presences: - self.s._inbound_presence_filter(presence) + self.s._handle_presence( + presence, + presence.from_, + False, + ) self.assertSequenceEqual( base.mock_calls, @@ -2697,7 +2768,11 @@ def mkpresence(nick, is_self=False): )) for presence in occupant_presences: - self.s._inbound_presence_filter(presence) + self.s._handle_presence( + presence, + presence.from_, + False, + ) self.s._inbound_message(msg) @@ -2718,7 +2793,11 @@ def test_muc_is_untracked_when_user_leaves(self): presence.xep0045_muc_user = muc_xso.UserExt() presence.xep0045_muc_user.status_codes.add(110) - self.s._inbound_presence_filter(presence) + self.s._handle_presence( + presence, + presence.from_, + False, + ) run_coroutine(asyncio.sleep(0)) self.assertTrue(future.done()) @@ -2730,7 +2809,11 @@ def test_muc_is_untracked_when_user_leaves(self): presence.xep0045_muc_user = muc_xso.UserExt() presence.xep0045_muc_user.status_codes.add(110) - self.s._inbound_presence_filter(presence) + self.s._handle_presence( + presence, + presence.from_, + False, + ) run_coroutine(asyncio.sleep(0)) with self.assertRaises(KeyError): @@ -2826,7 +2909,11 @@ def test_stream_destruction_with_autorejoin(self): status_codes={110} ) - self.s._inbound_presence_filter(presence) + self.s._handle_presence( + presence, + presence.from_, + False, + ) run_coroutine(asyncio.sleep(0)) self.assertTrue(fut1.done()) @@ -2914,7 +3001,11 @@ def extract(items, op): presence.xep0045_muc_user = muc_xso.UserExt( status_codes={110} ) - self.s._inbound_presence_filter(presence) + self.s._handle_presence( + presence, + presence.from_, + False, + ) presence = aioxmpp.stanza.Presence( type_=aioxmpp.structs.PresenceType.AVAILABLE, @@ -2924,7 +3015,11 @@ def extract(items, op): presence.xep0045_muc_user = muc_xso.UserExt( status_codes={110} ) - self.s._inbound_presence_filter(presence) + self.s._handle_presence( + presence, + presence.from_, + False, + ) run_coroutine(asyncio.sleep(0)) @@ -2984,7 +3079,11 @@ def test_stream_destruction_without_autorejoin(self): status_codes={110} ) - self.s._inbound_presence_filter(presence) + self.s._handle_presence( + presence, + presence.from_, + False, + ) run_coroutine(asyncio.sleep(0)) self.assertTrue(fut1.done()) @@ -3065,7 +3164,11 @@ def test_disconnect_all_mucs_on_shutdown(self): TEST_MUC_JID.replace(localpart="bar"), "thirdwitch") - self.s._inbound_presence_filter(presence) + self.s._handle_presence( + presence, + presence.from_, + False, + ) base = unittest.mock.Mock() From 6ed25b9c3e35cf6a598d67a053b11d5afac46f4a Mon Sep 17 00:00:00 2001 From: Jonas Wielicki Date: Mon, 3 Apr 2017 19:03:11 +0200 Subject: [PATCH 14/40] im-muc: Port MUCClient message handling to IMDispatcher --- aioxmpp/muc/service.py | 30 ++++++------- tests/muc/test_service.py | 95 ++++++++++++++++++++++++++++----------- 2 files changed, 82 insertions(+), 43 deletions(-) diff --git a/aioxmpp/muc/service.py b/aioxmpp/muc/service.py index 3846e153..c9a50048 100644 --- a/aioxmpp/muc/service.py +++ b/aioxmpp/muc/service.py @@ -527,6 +527,10 @@ def _resume(self): self._active = False self.on_resume() + def _handle_message(self, message, peer, sent, source): + if not sent: + self._inbound_message(message) + def _inbound_message(self, stanza): self._service.logger.debug("%s: inbound message %r", self._mucjid, @@ -1131,14 +1135,19 @@ def _handle_presence(self, stanza, peer, sent): return None return stanza - def _inbound_message(self, stanza): - mucjid = stanza.from_.bare() + @aioxmpp.service.depfilter( + aioxmpp.im.dispatcher.IMDispatcher, + "message_filter") + def _handle_message(self, message, peer, sent, source): + mucjid = peer.bare() try: muc = self._joined_mucs[mucjid] except KeyError: - pass - else: - muc._inbound_message(stanza) + return message + + muc._handle_message( + message, peer, sent, source + ) def _muc_exited(self, muc, stanza, *args, **kwargs): try: @@ -1219,17 +1228,6 @@ def join(self, mucjid, nick, *, if mucjid in self._pending_mucs: raise ValueError("already joined") - try: - self.client.stream.register_message_callback( - aioxmpp.structs.MessageType.GROUPCHAT, - mucjid, - self._inbound_message - ) - except ValueError: - raise RuntimeError( - "message callback for MUC already in use" - ) - room = Room(self, mucjid) room.autorejoin = autorejoin room.password = password diff --git a/tests/muc/test_service.py b/tests/muc/test_service.py index 6f5d2aaf..3a8053f8 100644 --- a/tests/muc/test_service.py +++ b/tests/muc/test_service.py @@ -1212,6 +1212,34 @@ def test__inbound_muc_user_presence_emits_on_various_changes(self): "moderator" ) + def test__handle_message_calls_inbound_for_received(self): + with unittest.mock.patch.object( + self.jmuc, + "_inbound_message") as _inbound_message: + self.jmuc._handle_message( + unittest.mock.sentinel.msg, + unittest.mock.sentinel.peer, + False, + unittest.mock.sentinel.source, + ) + + _inbound_message.assert_called_once_with( + unittest.mock.sentinel.msg + ) + + def test__handle_message_does_not_call_inbound_for_sent(self): + with unittest.mock.patch.object( + self.jmuc, + "_inbound_message") as _inbound_message: + self.jmuc._handle_message( + unittest.mock.sentinel.msg, + unittest.mock.sentinel.peer, + True, + unittest.mock.sentinel.source, + ) + + _inbound_message.assert_not_called() + def test__inbound_message_handles_subject_of_occupant(self): pres = aioxmpp.stanza.Presence( from_=TEST_MUC_JID.replace(resource="secondwitch"), @@ -2288,6 +2316,30 @@ def test_handle_presence_is_decorated(self): ) ) + def test_handle_message_is_decorated(self): + self.assertTrue( + aioxmpp.service.is_depfilter_handler( + im_dispatcher.IMDispatcher, + "message_filter", + muc_service.MUCClient._handle_message, + ) + ) + + def test_handle_message_ignores_unknown_groupchat_stanza(self): + msg = aioxmpp.Message( + type_=aioxmpp.MessageType.GROUPCHAT, + from_=TEST_MUC_JID.replace(resource="firstwitch"), + ) + self.assertIs( + msg, + self.s._handle_message( + msg, + msg.from_, + False, + unittest.mock.sentinel.source, + ) + ) + def test__stream_established_is_decorated(self): self.assertTrue( aioxmpp.service.is_depsignal_handler( @@ -2419,11 +2471,7 @@ def test_join_without_password_or_history(self): stanza.xep0045_muc.history ) - self.cc.stream.register_message_callback.assert_called_with( - aioxmpp.structs.MessageType.GROUPCHAT, - TEST_MUC_JID, - self.s._inbound_message - ) + self.cc.stream.register_message_callback.assert_not_called() self.assertFalse(future.done()) @@ -2581,17 +2629,6 @@ def test_join_rejects_non_bare_muc_jid(self): "firstwitch" ) - def test_join_raises_if_message_callback_is_in_use(self): - self.cc.stream.register_message_callback.side_effect = ValueError() - - with self.assertRaisesRegex( - RuntimeError, - "message callback for MUC already in use"): - self.s.join( - TEST_MUC_JID, - "firstwitch" - ) - def test_future_receives_exception_on_join_error(self): room, future = self.s.join(TEST_MUC_JID, "thirdwitch") @@ -2758,13 +2795,10 @@ def mkpresence(nick, is_self=False): type_=aioxmpp.structs.MessageType.GROUPCHAT, ) - base = unittest.mock.Mock() - with contextlib.ExitStack() as stack: - stack.enter_context(unittest.mock.patch.object( + _handle_message = stack.enter_context(unittest.mock.patch.object( room, - "_inbound_message", - new=base.inbound_message + "_handle_message", )) for presence in occupant_presences: @@ -2774,13 +2808,20 @@ def mkpresence(nick, is_self=False): False, ) - self.s._inbound_message(msg) + self.assertIsNone( + self.s._handle_message( + msg, + msg.from_, + unittest.mock.sentinel.sent, + unittest.mock.sentinel.source, + ) + ) - self.assertSequenceEqual( - base.mock_calls, - [ - unittest.mock.call.inbound_message(msg) - ] + _handle_message.assert_called_once_with( + msg, + msg.from_, + unittest.mock.sentinel.sent, + unittest.mock.sentinel.source, ) def test_muc_is_untracked_when_user_leaves(self): From 1e98e5bc53790169ec25064533da1abf50660c1a Mon Sep 17 00:00:00 2001 From: Jonas Wielicki Date: Mon, 3 Apr 2017 19:04:16 +0200 Subject: [PATCH 15/40] im-muc: Erase MUCClient message tracking implementation --- aioxmpp/muc/service.py | 79 ----------- tests/muc/test_service.py | 285 -------------------------------------- 2 files changed, 364 deletions(-) diff --git a/aioxmpp/muc/service.py b/aioxmpp/muc/service.py index c9a50048..e83da570 100644 --- a/aioxmpp/muc/service.py +++ b/aioxmpp/muc/service.py @@ -422,14 +422,6 @@ def __init__(self, service, mucjid): self.autorejoin = False self.password = None - self.on_exit.connect(self._cleanup_tracking) - self.on_resume.connect(self._cleanup_tracking) - - def _cleanup_tracking(self, *args, **kwargs): - for tracker in self._tracking.values(): - tracker.state = aioxmpp.tracking.MessageState.CLOSED - self._tracking.clear() - @property def service(self): return self._service @@ -852,77 +844,6 @@ def on_exit(*args, **kwargs): yield from fut - def _tracking_timeout(self, id_, tracker): - tracker.state = aioxmpp.tracking.MessageState.TIMED_OUT - try: - existing = self._tracking.pop[id_] - except KeyError: - pass - else: - if existing is tracker: - del self._tracking[id_] - - def send_tracked_message(self, body_or_stanza, *, - timeout=timedelta(seconds=120)): - """ - Send a tracked message. The first argument can either be a - :class:`~.Message` or a mapping compatible with - :attr:`~.Message.body`. - - Return a :class:`~.tracking.MessageTracker` which tracks the - message. See the documentation of :class:`~.MessageTracker` and - :class:`~.MessageState` for more details on tracking in general. - - Tracking a MUC groupchat message supports tracking up to the - :attr:`~.MessageState.DELIVERED_TO_RECIPIENT` state. If a `timeout` is - given, it must be a :class:`~datetime.timedelta` indicating the time - span after which the tracking shall time out. `timeout` may be - :data:`None` to let the tracking never expire. - - .. warning:: - - Some MUC implementations rewrite the ``id`` when the message is - reflected in the MUC. In that case, tracking cannot succeed beyond - the :attr:`~.MessageState.DELIVERED_TO_SERVER` state, which is - provided by the basic tracking interface. - - To support these implementations, the `timeout` defaults at 120 - seconds; this avoids that sending a message becomes a memory leak. - - If the chat is exited in the meantime, the messages are set to - :attr:`~.MessageState.CLOSED` state. This also happens on suspension - and resumption. - """ - if isinstance(body_or_stanza, aioxmpp.stanza.Message): - message = body_or_stanza - message.type_ = aioxmpp.structs.MessageType.GROUPCHAT - message.to = self.mucjid - else: - message = aioxmpp.stanza.Message( - type_=aioxmpp.structs.MessageType.GROUPCHAT, - to=self.mucjid - ) - message.body.update(body_or_stanza) - - tracker = aioxmpp.tracking.MessageTracker() - token = self.service.client.stream.enqueue( - message, - on_state_change=tracker.on_stanza_state_change - ) - tracker.token = token - - self._tracking[message.id_] = tracker - - if timeout is not None: - asyncio.get_event_loop().call_later( - timeout.total_seconds(), - self._tracking_timeout, - message.id_, - tracker - ) - - return tracker - @asyncio.coroutine def request_voice(self): """ diff --git a/tests/muc/test_service.py b/tests/muc/test_service.py index 3a8053f8..7d87c730 100644 --- a/tests/muc/test_service.py +++ b/tests/muc/test_service.py @@ -35,7 +35,6 @@ import aioxmpp.service as service import aioxmpp.stanza import aioxmpp.structs -import aioxmpp.tracking as tracking import aioxmpp.utils as utils from aioxmpp.testutils import ( @@ -1906,290 +1905,6 @@ def test_occupants(self): self.assertIs(self.jmuc.occupants[0], self.jmuc.this_occupant) - def test_send_tracked_message_with_body(self): - stanza = None - set_on_state_change = None - result = object() - - def setup_stanza(stanza_to_send, *, on_state_change=None): - nonlocal stanza, set_on_state_change - self.assertIsNone(stanza) - stanza_to_send.autoset_id() - stanza = stanza_to_send - set_on_state_change = on_state_change - return result - - body = { - None: "foo" - } - - with unittest.mock.patch.object( - self.base.service.client.stream, - "enqueue", - new=setup_stanza): - tracker = self.jmuc.send_tracked_message(body) - - self.assertIsNotNone(stanza) - self.assertIsInstance( - stanza, - aioxmpp.stanza.Message - ) - self.assertEqual( - stanza.type_, - aioxmpp.structs.MessageType.GROUPCHAT, - ) - self.assertEqual( - stanza.to, - self.mucjid - ) - self.assertDictEqual( - stanza.body, - body - ) - - self.assertEqual( - set_on_state_change, - tracker.on_stanza_state_change - ) - - self.assertIsInstance( - tracker, - tracking.MessageTracker - ) - self.assertEqual( - tracker.token, - result, - ) - - self.assertEqual( - tracker.state, - tracking.MessageState.IN_TRANSIT - ) - - reflected = aioxmpp.stanza.Message( - type_=aioxmpp.structs.MessageType.GROUPCHAT, - id_=stanza.id_ - ) - - self.jmuc._inbound_message(reflected) - - self.assertEqual( - tracker.state, - tracking.MessageState.DELIVERED_TO_RECIPIENT - ) - - def test_tracking_deals_with_invalid_state(self): - stanza = None - set_on_state_change = None - result = object() - - def setup_stanza(stanza_to_send, *, on_state_change=None): - nonlocal stanza, set_on_state_change - self.assertIsNone(stanza) - stanza_to_send.autoset_id() - stanza = stanza_to_send - set_on_state_change = on_state_change - return result - - body = { - None: "foo" - } - - with unittest.mock.patch.object( - self.base.service.client.stream, - "enqueue", - new=setup_stanza): - tracker = self.jmuc.send_tracked_message(body) - - self.assertIsNotNone(stanza) - self.assertIsInstance( - stanza, - aioxmpp.stanza.Message - ) - self.assertEqual( - stanza.type_, - aioxmpp.structs.MessageType.GROUPCHAT, - ) - self.assertEqual( - stanza.to, - self.mucjid - ) - self.assertDictEqual( - stanza.body, - body - ) - - self.assertEqual( - set_on_state_change, - tracker.on_stanza_state_change - ) - - self.assertIsInstance( - tracker, - tracking.MessageTracker - ) - self.assertEqual( - tracker.token, - result, - ) - - self.assertEqual( - tracker.state, - tracking.MessageState.IN_TRANSIT - ) - - tracker.state = tracking.MessageState.SEEN_BY_RECIPIENT - - reflected = aioxmpp.stanza.Message( - type_=aioxmpp.structs.MessageType.GROUPCHAT, - id_=stanza.id_ - ) - - self.jmuc._inbound_message(reflected) - - def test_send_tracked_message_with_timeout(self): - stanza = None - set_on_state_change = None - result = object() - - def setup_stanza(stanza_to_send, *, on_state_change=None): - nonlocal stanza, set_on_state_change - self.assertIsNone(stanza) - stanza_to_send.autoset_id() - stanza = stanza_to_send - set_on_state_change = on_state_change - return result - - body = { - None: "foo" - } - - with unittest.mock.patch.object( - self.base.service.client.stream, - "enqueue", - new=setup_stanza): - tracker = self.jmuc.send_tracked_message( - body, - timeout=timedelta(seconds=0.05) - ) - - self.assertEqual( - tracker.state, - tracking.MessageState.IN_TRANSIT - ) - - run_coroutine(asyncio.sleep(0.06)) - - self.assertEqual( - tracker.state, - tracking.MessageState.TIMED_OUT - ) - - def test_send_tracked_message_with_stanza(self): - stanza = aioxmpp.stanza.Message( - type_=aioxmpp.structs.MessageType.CHAT, - to=TEST_ENTITY_JID - ) - - def setup_stanza(stanza_to_send, *, on_state_change=None): - nonlocal set_stanza, set_on_state_change - self.assertIsNone(set_stanza) - stanza_to_send.autoset_id() - set_stanza = stanza_to_send - set_on_state_change = on_state_change - return result - - set_stanza = None - set_on_state_change = None - result = object() - - with unittest.mock.patch.object( - self.base.service.client.stream, - "enqueue", - new=setup_stanza): - tracker = self.jmuc.send_tracked_message( - stanza, - ) - - self.assertIs(set_stanza, stanza) - - # assure that critical attributes are overriden - self.assertEqual( - stanza.type_, - aioxmpp.structs.MessageType.GROUPCHAT - ) - self.assertEqual(stanza.to, self.mucjid) - - def test_tracked_messages_are_set_to_unknown_on_exit(self): - stanza = aioxmpp.stanza.Message( - type_=aioxmpp.structs.MessageType.CHAT, - to=TEST_ENTITY_JID - ) - - def setup_stanza(stanza_to_send, *, on_state_change=None): - nonlocal set_stanza, set_on_state_change - self.assertIsNone(set_stanza) - stanza_to_send.autoset_id() - set_stanza = stanza_to_send - set_on_state_change = on_state_change - return result - - set_stanza = None - set_on_state_change = None - result = object() - - with unittest.mock.patch.object( - self.base.service.client.stream, - "enqueue", - new=setup_stanza): - tracker = self.jmuc.send_tracked_message( - stanza, - ) - - self.assertIs(set_stanza, stanza) - - self.jmuc.on_exit(object(), object(), object()) - - self.assertEqual( - tracker.state, - tracking.MessageState.CLOSED - ) - - def test_tracked_messages_are_set_to_unknown_on_resume(self): - stanza = aioxmpp.stanza.Message( - type_=aioxmpp.structs.MessageType.CHAT, - to=TEST_ENTITY_JID - ) - - def setup_stanza(stanza_to_send, *, on_state_change=None): - nonlocal set_stanza, set_on_state_change - self.assertIsNone(set_stanza) - stanza_to_send.autoset_id() - set_stanza = stanza_to_send - set_on_state_change = on_state_change - return result - - set_stanza = None - set_on_state_change = None - result = object() - - with unittest.mock.patch.object( - self.base.service.client.stream, - "enqueue", - new=setup_stanza): - tracker = self.jmuc.send_tracked_message( - stanza, - ) - - self.assertIs(set_stanza, stanza) - - self.jmuc.on_resume() - - self.assertEqual( - tracker.state, - tracking.MessageState.CLOSED - ) - def test_request_voice(self): run_coroutine(self.jmuc.request_voice()) From 31c2e6b91ca4b99fc435b49599a751e418c819e9 Mon Sep 17 00:00:00 2001 From: Jonas Wielicki Date: Tue, 4 Apr 2017 15:38:17 +0200 Subject: [PATCH 16/40] im-muc: Port Room and Occupant to AbstractConversation{,Member} API, part 1 * Provide standardised Occupant attributes * Provide me and members attributes on Room --- aioxmpp/muc/service.py | 123 +++++++------- tests/muc/test_e2e.py | 12 +- tests/muc/test_service.py | 330 +++++++++++++++++++++++++++----------- 3 files changed, 307 insertions(+), 158 deletions(-) diff --git a/aioxmpp/muc/service.py b/aioxmpp/muc/service.py index e83da570..76a0f32b 100644 --- a/aioxmpp/muc/service.py +++ b/aioxmpp/muc/service.py @@ -31,6 +31,7 @@ import aioxmpp.stanza import aioxmpp.structs import aioxmpp.tracking +import aioxmpp.im.conversation import aioxmpp.im.dispatcher from . import xso as muc_xso @@ -87,14 +88,15 @@ class _OccupantDiffClass(Enum): LEFT = 2 -class Occupant: +class Occupant(aioxmpp.im.conversation.AbstractConversationMember): """ A tracking object to track a single occupant in a :class:`Room`. - .. attribute:: occupantjid + .. autoattribute:: direct_jid - The occupant JID of the occupant; this is the bare JID of the - :class:`Room`, with the nick of the occupant as resourcepart. + .. autoattribute:: conversation_jid + + .. autoattribute:: nick .. attribute:: presence_state @@ -113,34 +115,42 @@ class Occupant: The current role of the occupant within the room. - .. attribute:: jid - - The actual JID of the occupant, if it is known. - """ def __init__(self, occupantjid, + is_self, presence_state=aioxmpp.structs.PresenceState(available=True), presence_status={}, affiliation=None, role=None, jid=None): - super().__init__() - self.occupantjid = occupantjid + super().__init__(occupantjid, is_self) self.presence_state = presence_state self.presence_status = aioxmpp.structs.LanguageMap(presence_status) self.affiliation = affiliation self.role = role - self.jid = jid - self.is_self = False + self._direct_jid = jid + + @property + def direct_jid(self): + """ + The real :class:`~aioxmpp.JID` of the occupant. + + If the MUC is anonymous and we do not have the permission to see the + real JIDs of occupants, this is :data:`None`. + """ + return self._direct_jid @property def nick(self): - return self.occupantjid.resource + """ + The nickname of the occupant. + """ + return self.conversation_jid.resource @classmethod - def from_presence(cls, presence): + def from_presence(cls, presence, is_self): try: item = presence.xep0045_muc_user.items[0] except (AttributeError, IndexError): @@ -154,6 +164,7 @@ def from_presence(cls, presence): return cls( occupantjid=presence.from_, + is_self=is_self, presence_state=aioxmpp.structs.PresenceState.from_stanza(presence), presence_status=aioxmpp.structs.LanguageMap(presence.status), affiliation=affiliation, @@ -162,27 +173,27 @@ def from_presence(cls, presence): ) def update(self, other): - if self.occupantjid != other.occupantjid: + if self.conversation_jid != other.conversation_jid: raise ValueError("occupant JID mismatch") self.presence_state = other.presence_state self.presence_status.clear() self.presence_status.update(other.presence_status) self.affiliation = other.affiliation self.role = other.role - self.jid = other.jid + self._direct_jid = other.direct_jid -class Room: +class Room(aioxmpp.im.conversation.AbstractConversation): """ Interface to a :xep:`0045` multi-user-chat room. - .. autoattribute:: mucjid + .. autoattribute:: jid .. autoattribute:: active .. autoattribute:: joined - .. autoattribute:: this_occupant + .. autoattribute:: me .. autoattribute:: subject @@ -203,7 +214,7 @@ class Room: The following methods and properties provide interaction with the MUC itself: - .. autoattribute:: occupants + .. autoattribute:: members .. automethod:: change_nick @@ -213,8 +224,6 @@ class Room: .. automethod:: request_voice - .. automethod:: send_tracked_message - .. automethod:: set_role .. automethod:: set_affiliation @@ -409,8 +418,7 @@ class Room: on_subject_change = aioxmpp.callbacks.Signal() def __init__(self, service, mucjid): - super().__init__() - self._service = service + super().__init__(service) self._mucjid = mucjid self._occupant_info = {} self._subject = aioxmpp.structs.LanguageMap() @@ -467,7 +475,7 @@ def subject_setter(self): return self._subject_setter @property - def this_occupant(self): + def me(self): """ A :class:`Occupant` instance which tracks the local user. This is :data:`None` until :meth:`on_enter` is emitted; it is never set to @@ -477,7 +485,7 @@ def this_occupant(self): return self._this_occupant @property - def mucjid(self): + def jid(self): """ The (bare) :class:`aioxmpp.JID` of the MUC which this :class:`Room` tracks. @@ -485,7 +493,7 @@ def mucjid(self): return self._mucjid @property - def occupants(self): + def members(self): """ A copy of the list of occupants. The local user is always the first item in the list, unless the :meth:`on_enter` has not fired yet. @@ -507,7 +515,7 @@ def _disconnect(self): return self.on_exit( None, - self.this_occupant, + self._this_occupant, LeaveMode.DISCONNECTED ) self._joined = False @@ -628,7 +636,7 @@ def _diff_presence(self, stanza, info, existing): return result def _handle_self_presence(self, stanza): - info = Occupant.from_presence(stanza) + info = Occupant.from_presence(stanza, True) if not self._active: if stanza.type_ == aioxmpp.structs.PresenceType.UNAVAILABLE: @@ -642,7 +650,6 @@ def _handle_self_presence(self, stanza): self._service.logger.debug("%s: not active, configuring", self._mucjid) self._this_occupant = info - info.is_self = True self._joined = True self._active = True self.on_enter(stanza, info) @@ -654,9 +661,9 @@ def _handle_self_presence(self, stanza): new_nick, = data self._service.logger.debug("%s: nick changed: %r -> %r", self._mucjid, - existing.occupantjid.resource, + existing.conversation_jid.resource, new_nick) - existing.occupantjid = existing.occupantjid.replace( + existing._conversation_jid = existing.conversation_jid.replace( resource=new_nick ) self.on_nick_change(stanza, existing) @@ -677,15 +684,15 @@ def _inbound_muc_user_presence(self, stanza): if (110 in stanza.xep0045_muc_user.status_codes or (self._this_occupant is not None and - self._this_occupant.occupantjid == stanza.from_)): + self._this_occupant.conversation_jid == stanza.from_)): self._service.logger.debug("%s: is self-presence", self._mucjid) self._handle_self_presence(stanza) return - info = Occupant.from_presence(stanza) + info = Occupant.from_presence(stanza, False) try: - existing = self._occupant_info[info.occupantjid] + existing = self._occupant_info[info.conversation_jid] except KeyError: if stanza.type_ == aioxmpp.structs.PresenceType.UNAVAILABLE: self._service.logger.debug( @@ -694,24 +701,28 @@ def _inbound_muc_user_presence(self, stanza): stanza.from_, ) return - self._occupant_info[info.occupantjid] = info + self._occupant_info[info.conversation_jid] = info self.on_join(stanza, info) return mode, data = self._diff_presence(stanza, info, existing) if mode == _OccupantDiffClass.NICK_CHANGED: new_nick, = data - del self._occupant_info[existing.occupantjid] - existing.occupantjid = existing.occupantjid.replace( + del self._occupant_info[existing.conversation_jid] + existing._conversation_jid = existing.conversation_jid.replace( resource=new_nick ) - self._occupant_info[existing.occupantjid] = existing + self._occupant_info[existing.conversation_jid] = existing self.on_nick_change(stanza, existing) elif mode == _OccupantDiffClass.LEFT: mode, actor, reason = data existing.update(info) self.on_leave(stanza, existing, mode, actor=actor, reason=reason) - del self._occupant_info[existing.occupantjid] + del self._occupant_info[existing.conversation_jid] + + @asyncio.coroutine + def send_message_tracked(self, body): + pass @asyncio.coroutine def change_nick(self, new_nick): @@ -760,7 +771,7 @@ def set_role(self, nick, role, *, reason=None): iq = aioxmpp.stanza.IQ( type_=aioxmpp.structs.IQType.SET, - to=self.mucjid + to=self._mucjid ) iq.payload = muc_xso.AdminQuery( @@ -783,7 +794,7 @@ def set_affiliation(self, jid, affiliation, *, reason=None): :attr:`mucjid`. """ return (yield from self.service.set_affiliation( - self.mucjid, + self._mucjid, jid, affiliation, reason=reason)) @@ -798,7 +809,7 @@ def set_subject(self, subject): msg = aioxmpp.stanza.Message( type_=aioxmpp.structs.MessageType.GROUPCHAT, - to=self.mucjid + to=self._mucjid ) msg.subject.update(subject) @@ -860,7 +871,7 @@ def request_voice(self): """ msg = aioxmpp.Message( - to=self.mucjid, + to=self._mucjid, type_=aioxmpp.MessageType.NORMAL ) @@ -943,10 +954,10 @@ def _stream_established(self): for muc, fut, nick, history in self._pending_mucs.values(): if muc.joined: - self.logger.debug("%s: resuming", muc.mucjid) + self.logger.debug("%s: resuming", muc.jid) muc._resume() - self.logger.debug("%s: sending join presence", muc.mucjid) - self._send_join_presence(muc.mucjid, history, nick, muc.password) + self.logger.debug("%s: sending join presence", muc.jid) + self._send_join_presence(muc.jid, history, nick, muc.password) @aioxmpp.service.depsignal(aioxmpp.Client, "on_stream_destroyed") def _stream_destroyed(self): @@ -959,15 +970,15 @@ def _stream_destroyed(self): if not muc.autorejoin: self.logger.debug( "%s: pending without autorejoin -> ConnectionError", - muc.mucjid + muc.jid ) fut.set_exception(ConnectionError()) else: self.logger.debug( "%s: pending with autorejoin -> keeping", - muc.mucjid + muc.jid ) - new_pending[muc.mucjid] = (muc, fut) + tuple(more) + new_pending[muc.jid] = (muc, fut) + tuple(more) self._pending_mucs = new_pending for muc in list(self._joined_mucs.values()): @@ -975,18 +986,18 @@ def _stream_destroyed(self): self.logger.debug( "%s: connected with autorejoin, suspending and adding to " "pending", - muc.mucjid + muc.jid ) muc._suspend() - self._pending_mucs[muc.mucjid] = ( - muc, None, muc.this_occupant.nick, muc_xso.History( + self._pending_mucs[muc.jid] = ( + muc, None, muc.me.nick, muc_xso.History( since=datetime.utcnow() ) ) else: self.logger.debug( "%s: connected with autorejoin, disconnecting", - muc.mucjid + muc.jid ) muc._disconnect() @@ -1072,9 +1083,9 @@ def _handle_message(self, message, peer, sent, source): def _muc_exited(self, muc, stanza, *args, **kwargs): try: - del self._joined_mucs[muc.mucjid] + del self._joined_mucs[muc.jid] except KeyError: - _, fut, *_ = self._pending_mucs.pop(muc.mucjid) + _, fut, *_ = self._pending_mucs.pop(muc.jid) if not fut.done(): fut.set_result(None) diff --git a/tests/muc/test_e2e.py b/tests/muc/test_e2e.py index 999947fe..98087fa1 100644 --- a/tests/muc/test_e2e.py +++ b/tests/muc/test_e2e.py @@ -93,7 +93,7 @@ def test_join(self): recvd_future = asyncio.Future() def onjoin(presence, occupant, **kwargs): - if occupant.occupantjid.resource != "thirdwitch": + if occupant.nick != "thirdwitch": return nonlocal recvd_future recvd_future.set_result((presence, occupant)) @@ -107,13 +107,13 @@ def onjoin(presence, occupant, **kwargs): presence, occupant = yield from recvd_future self.assertEqual( - occupant.occupantjid, + occupant.conversation_jid, self.mucjid.replace(resource="thirdwitch"), ) self.assertEqual( presence.from_, - occupant.occupantjid, + occupant.conversation_jid, ) @blocking_timed @@ -322,8 +322,8 @@ def onnickchange(fut, presence, occupant, **kwargs): yield from self.firstroom.change_nick("oldhag") presence, occupant = yield from self_future - self.assertEqual(occupant, self.firstroom.this_occupant) - self.assertEqual(occupant.occupantjid.resource, "oldhag") + self.assertEqual(occupant, self.firstroom.me) + self.assertEqual(occupant.nick, "oldhag") presence, occupant = yield from foreign_future - self.assertEqual(occupant.occupantjid.resource, "oldhag") + self.assertEqual(occupant.nick, "oldhag") diff --git a/tests/muc/test_service.py b/tests/muc/test_service.py index 7d87c730..f848619e 100644 --- a/tests/muc/test_service.py +++ b/tests/muc/test_service.py @@ -54,22 +54,32 @@ class TestOccupant(unittest.TestCase): - def test_init(self): + def test_init_mostly_default(self): occ = muc_service.Occupant( TEST_MUC_JID.replace(resource="firstwitch"), + unittest.mock.sentinel.is_self, + ) + self.assertEqual(occ.is_self, unittest.mock.sentinel.is_self) + self.assertEqual( + occ.conversation_jid, + TEST_MUC_JID.replace(resource="firstwitch") + ) + self.assertEqual( + occ.nick, + "firstwitch" + ) + self.assertEqual( + occ.presence_state, + aioxmpp.structs.PresenceState(available=True) ) - self.assertEqual(occ.occupantjid, - TEST_MUC_JID.replace(resource="firstwitch")) - self.assertEqual(occ.nick, "firstwitch") - self.assertEqual(occ.presence_state, - aioxmpp.structs.PresenceState(available=True)) self.assertDictEqual(occ.presence_status, {}) self.assertIsInstance(occ.presence_status, aioxmpp.structs.LanguageMap) self.assertIsNone(occ.affiliation) self.assertIsNone(occ.role) - self.assertFalse(occ.is_self) + self.assertEqual(occ.is_self, unittest.mock.sentinel.is_self) + def test_init_full(self): status = { aioxmpp.structs.LanguageTag.fromstr("de-de"): "Hex-hex!", None: "Witchcraft!" @@ -77,6 +87,7 @@ def test_init(self): occ = muc_service.Occupant( TEST_MUC_JID.replace(resource="firstwitch"), + unittest.mock.sentinel.is_self, presence_state=aioxmpp.structs.PresenceState( available=True, show=aioxmpp.PresenceShow.AWAY, @@ -119,11 +130,14 @@ def test_init(self): ) self.assertEqual( - occ.jid, + occ.direct_jid, TEST_ENTITY_JID ) - self.assertFalse(occ.is_self) + self.assertEqual( + occ.is_self, + unittest.mock.sentinel.is_self, + ) def test_from_presence_can_deal_with_sparse_presence(self): presence = aioxmpp.stanza.Presence( @@ -134,28 +148,35 @@ def test_from_presence_can_deal_with_sparse_presence(self): presence.status[None] = "foo" - occ = muc_service.Occupant.from_presence(presence) + occ = muc_service.Occupant.from_presence( + presence, + unittest.mock.sentinel.is_self + ) self.assertIsInstance(occ, muc_service.Occupant) - self.assertEqual(occ.occupantjid, presence.from_) + self.assertEqual(occ.conversation_jid, presence.from_) self.assertEqual(occ.nick, presence.from_.resource) self.assertDictEqual(occ.presence_status, presence.status) self.assertIsNone(occ.affiliation) self.assertIsNone(occ.role) - self.assertIsNone(occ.jid) + self.assertIsNone(occ.direct_jid) + self.assertEqual(occ.is_self, unittest.mock.sentinel.is_self) presence.status[None] = "foo" presence.xep0045_muc_user = muc_xso.UserExt() - occ = muc_service.Occupant.from_presence(presence) + occ = muc_service.Occupant.from_presence( + presence, + unittest.mock.sentinel.is_self, + ) self.assertIsInstance(occ, muc_service.Occupant) - self.assertEqual(occ.occupantjid, presence.from_) + self.assertEqual(occ.conversation_jid, presence.from_) self.assertEqual(occ.nick, presence.from_.resource) self.assertDictEqual(occ.presence_status, presence.status) self.assertIsNone(occ.affiliation) self.assertIsNone(occ.role) - self.assertIsNone(occ.jid) + self.assertIsNone(occ.direct_jid) def test_from_presence_extracts_what_it_can_get(self): presence = aioxmpp.stanza.Presence( @@ -176,36 +197,49 @@ def test_from_presence_extracts_what_it_can_get(self): ] ) - occ = muc_service.Occupant.from_presence(presence) + occ = muc_service.Occupant.from_presence( + presence, + unittest.mock.sentinel.is_self + ) self.assertIsInstance(occ, muc_service.Occupant) - self.assertEqual(occ.occupantjid, presence.from_) + self.assertEqual(occ.conversation_jid, presence.from_) self.assertEqual(occ.nick, presence.from_.resource) self.assertDictEqual(occ.presence_status, presence.status) self.assertEqual(occ.affiliation, "owner") self.assertEqual(occ.role, "moderator") - self.assertEqual(occ.jid, TEST_ENTITY_JID) + self.assertEqual(occ.direct_jid, TEST_ENTITY_JID) + self.assertEqual(occ.is_self, unittest.mock.sentinel.is_self) def test_update_raises_for_different_occupantjids(self): presence = aioxmpp.stanza.Presence( from_=TEST_MUC_JID.replace(resource="secondwitch"), ) - occ = muc_service.Occupant.from_presence(presence) + occ = muc_service.Occupant.from_presence( + presence, + unittest.mock.sentinel.is_self, + ) presence = aioxmpp.stanza.Presence( from_=TEST_MUC_JID.replace(resource="firstwitch"), ) with self.assertRaisesRegex(ValueError, "mismatch"): - occ.update(muc_service.Occupant.from_presence(presence)) + occ.update(muc_service.Occupant.from_presence( + presence, + unittest.mock.sentinel.is_self, + )) def test_update_updates_all_the_fields(self): presence = aioxmpp.stanza.Presence( from_=TEST_MUC_JID.replace(resource="secondwitch"), ) - occ = muc_service.Occupant.from_presence(presence) + occ = muc_service.Occupant.from_presence( + presence, + unittest.mock.sentinel.is_self, + ) presence = aioxmpp.stanza.Presence( from_=TEST_MUC_JID.replace(resource="secondwitch"), @@ -227,13 +261,16 @@ def test_update_updates_all_the_fields(self): old_status_dict = occ.presence_status - occ.update(muc_service.Occupant.from_presence(presence)) - self.assertEqual(occ.occupantjid, presence.from_) + occ.update(muc_service.Occupant.from_presence( + presence, + unittest.mock.sentinel.is_self, + )) + self.assertEqual(occ.conversation_jid, presence.from_) self.assertEqual(occ.nick, presence.from_.resource) self.assertDictEqual(occ.presence_status, presence.status) self.assertEqual(occ.affiliation, "owner") self.assertEqual(occ.role, "moderator") - self.assertEqual(occ.jid, TEST_ENTITY_JID) + self.assertEqual(occ.direct_jid, TEST_ENTITY_JID) self.assertIs(occ.presence_status, old_status_dict) @@ -337,13 +374,13 @@ def test_event_attributes(self): def test_init(self): self.assertIs(self.jmuc.service, self.base.service) - self.assertEqual(self.jmuc.mucjid, self.mucjid) + self.assertEqual(self.jmuc.jid, self.mucjid) self.assertDictEqual(self.jmuc.subject, {}) self.assertIsInstance(self.jmuc.subject, aioxmpp.structs.LanguageMap) self.assertFalse(self.jmuc.joined) self.assertFalse(self.jmuc.active) self.assertIsNone(self.jmuc.subject_setter) - self.assertIsNone(self.jmuc.this_occupant) + self.assertIsNone(self.jmuc.me) self.assertFalse(self.jmuc.autorejoin) self.assertIsNone(self.jmuc.password) @@ -351,9 +388,9 @@ def test_service_is_not_writable(self): with self.assertRaises(AttributeError): self.jmuc.service = self.base.service - def test_mucjid_is_not_writable(self): + def test_jid_is_not_writable(self): with self.assertRaises(AttributeError): - self.jmuc.mucjid = self.mucjid + self.jmuc.jid = self.mucjid def test_active_is_not_writable(self): with self.assertRaises(AttributeError): @@ -371,10 +408,11 @@ def test_joined_is_not_writable(self): with self.assertRaises(AttributeError): self.jmuc.joined = True - def test_this_occupant_is_not_writable(self): + def test_me_is_not_writable(self): with self.assertRaises(AttributeError): - self.jmuc.this_occupant = muc_service.Occupant( - TEST_MUC_JID.replace(resource="foo") + self.jmuc.me = muc_service.Occupant( + TEST_MUC_JID.replace(resource="foo"), + True, ) def test__suspend_with_autorejoin(self): @@ -401,7 +439,7 @@ def test__suspend_with_autorejoin(self): self.assertTrue(self.jmuc.joined) self.assertFalse(self.jmuc.active) - self.assertIsNotNone(self.jmuc.this_occupant) + self.assertIsNotNone(self.jmuc.me) self.assertSequenceEqual( self.base.mock_calls, @@ -437,7 +475,7 @@ def test__suspend_without_autorejoin(self): self.assertFalse(self.jmuc.active) self.assertTrue(self.jmuc.joined) - self.assertIsNotNone(self.jmuc.this_occupant) + self.assertIsNotNone(self.jmuc.me) self.assertSequenceEqual( self.base.mock_calls, @@ -470,14 +508,14 @@ def test__disconnect(self): self.assertFalse(self.jmuc.joined) self.assertFalse(self.jmuc.active) - self.assertIsNotNone(self.jmuc.this_occupant) + self.assertIsNotNone(self.jmuc.me) self.assertSequenceEqual( self.base.mock_calls, [ unittest.mock.call.on_exit( None, - self.jmuc.this_occupant, + self.jmuc.me, muc_service.LeaveMode.DISCONNECTED), ] ) @@ -508,7 +546,7 @@ def test__disconnect_during_suspend(self): self.assertFalse(self.jmuc.joined) self.assertFalse(self.jmuc.active) - self.assertIsNotNone(self.jmuc.this_occupant) + self.assertIsNotNone(self.jmuc.me) self.assertSequenceEqual( self.base.mock_calls, @@ -516,7 +554,7 @@ def test__disconnect_during_suspend(self): unittest.mock.call.on_suspend(), unittest.mock.call.on_exit( None, - self.jmuc.this_occupant, + self.jmuc.me, muc_service.LeaveMode.DISCONNECTED), ] ) @@ -563,7 +601,7 @@ def test__suspend__resume_cycle(self): self.assertTrue(self.jmuc.joined) self.assertFalse(self.jmuc.active) - old_occupant = self.jmuc.this_occupant + old_occupant = self.jmuc.me self.jmuc._resume() @@ -584,14 +622,14 @@ def test__suspend__resume_cycle(self): self.jmuc._inbound_muc_user_presence(presence) self.assertTrue(self.jmuc.active) - self.assertIsNot(old_occupant, self.jmuc.this_occupant) + self.assertIsNot(old_occupant, self.jmuc.me) self.assertSequenceEqual( self.base.mock_calls, [ unittest.mock.call.on_suspend(), unittest.mock.call.on_resume(), - unittest.mock.call.on_enter(presence, self.jmuc.this_occupant) + unittest.mock.call.on_enter(presence, self.jmuc.me) ] ) @@ -610,7 +648,10 @@ def test__inbound_muc_user_presence_emits_on_join_for_new_users(self): with unittest.mock.patch("aioxmpp.muc.service.Occupant") as Occupant: self.jmuc._inbound_muc_user_presence(presence) - Occupant.from_presence.assert_called_with(presence) + Occupant.from_presence.assert_called_with( + presence, + False, + ) self.assertSequenceEqual( self.base.mock_calls, @@ -645,12 +686,18 @@ def test__inbound_muc_user_presence_emits_on_leave_for_unavailable(self): original_Occupant = muc_service.Occupant with unittest.mock.patch("aioxmpp.muc.service.Occupant") as Occupant: - first = original_Occupant.from_presence(presence) + first = original_Occupant.from_presence( + presence, + False, + ) Occupant.from_presence.return_value = first self.jmuc._inbound_muc_user_presence(presence) - Occupant.from_presence.assert_called_with(presence) + Occupant.from_presence.assert_called_with( + presence, + False, + ) self.assertSequenceEqual( self.base.mock_calls, @@ -663,7 +710,10 @@ def test__inbound_muc_user_presence_emits_on_leave_for_unavailable(self): # update presence stanza presence.type_ = aioxmpp.structs.PresenceType.UNAVAILABLE - second = original_Occupant.from_presence(presence) + second = original_Occupant.from_presence( + presence, + False, + ) Occupant.from_presence.return_value = second self.jmuc._inbound_muc_user_presence(presence) @@ -695,12 +745,18 @@ def test__inbound_muc_user_presence_emits_on_leave_for_kick(self): original_Occupant = muc_service.Occupant with unittest.mock.patch("aioxmpp.muc.service.Occupant") as Occupant: - first = original_Occupant.from_presence(presence) + first = original_Occupant.from_presence( + presence, + False, + ) Occupant.from_presence.return_value = first self.jmuc._inbound_muc_user_presence(presence) - Occupant.from_presence.assert_called_with(presence) + Occupant.from_presence.assert_called_with( + presence, + False, + ) self.assertSequenceEqual( self.base.mock_calls, @@ -717,7 +773,10 @@ def test__inbound_muc_user_presence_emits_on_leave_for_kick(self): presence.xep0045_muc_user.items[0].role = "none" presence.xep0045_muc_user.items[0].actor = actor - second = original_Occupant.from_presence(presence) + second = original_Occupant.from_presence( + presence, + False, + ) Occupant.from_presence.return_value = second self.jmuc._inbound_muc_user_presence(presence) @@ -762,12 +821,15 @@ def test__inbound_muc_user_presence_emits_on_leave_for_ban(self): actor = object() original_Occupant = muc_service.Occupant with unittest.mock.patch("aioxmpp.muc.service.Occupant") as Occupant: - first = original_Occupant.from_presence(presence) + first = original_Occupant.from_presence(presence, False) Occupant.from_presence.return_value = first self.jmuc._inbound_muc_user_presence(presence) - Occupant.from_presence.assert_called_with(presence) + Occupant.from_presence.assert_called_with( + presence, + False, + ) self.assertSequenceEqual( self.base.mock_calls, @@ -785,7 +847,10 @@ def test__inbound_muc_user_presence_emits_on_leave_for_ban(self): presence.xep0045_muc_user.items[0].role = "none" presence.xep0045_muc_user.items[0].actor = actor - second = original_Occupant.from_presence(presence) + second = original_Occupant.from_presence( + presence, + False, + ) Occupant.from_presence.return_value = second self.jmuc._inbound_muc_user_presence(presence) @@ -833,12 +898,12 @@ def test__inbound_muc_user_presence_emits_on_leave_for_affiliation_change( original_Occupant = muc_service.Occupant actor = object() with unittest.mock.patch("aioxmpp.muc.service.Occupant") as Occupant: - first = original_Occupant.from_presence(presence) + first = original_Occupant.from_presence(presence, False) Occupant.from_presence.return_value = first self.jmuc._inbound_muc_user_presence(presence) - Occupant.from_presence.assert_called_with(presence) + Occupant.from_presence.assert_called_with(presence, False) self.assertSequenceEqual( self.base.mock_calls, @@ -856,7 +921,7 @@ def test__inbound_muc_user_presence_emits_on_leave_for_affiliation_change( presence.xep0045_muc_user.items[0].affiliation = "none" presence.xep0045_muc_user.items[0].role = "none" - second = original_Occupant.from_presence(presence) + second = original_Occupant.from_presence(presence, False) Occupant.from_presence.return_value = second self.jmuc._inbound_muc_user_presence(presence) @@ -908,12 +973,12 @@ def test__inbound_muc_user_presence_emits_on_leave_for_moderation_change( original_Occupant = muc_service.Occupant actor = object() with unittest.mock.patch("aioxmpp.muc.service.Occupant") as Occupant: - first = original_Occupant.from_presence(presence) + first = original_Occupant.from_presence(presence, False) Occupant.from_presence.return_value = first self.jmuc._inbound_muc_user_presence(presence) - Occupant.from_presence.assert_called_with(presence) + Occupant.from_presence.assert_called_with(presence, False) self.assertSequenceEqual( self.base.mock_calls, @@ -931,7 +996,7 @@ def test__inbound_muc_user_presence_emits_on_leave_for_moderation_change( presence.xep0045_muc_user.items[0].affiliation = "none" presence.xep0045_muc_user.items[0].role = "none" - second = original_Occupant.from_presence(presence) + second = original_Occupant.from_presence(presence, False) Occupant.from_presence.return_value = second self.jmuc._inbound_muc_user_presence(presence) @@ -971,14 +1036,13 @@ def test__inbound_muc_user_presence_emits_on_leave_for_system_shutdown( ) original_Occupant = muc_service.Occupant - actor = object() with unittest.mock.patch("aioxmpp.muc.service.Occupant") as Occupant: - first = original_Occupant.from_presence(presence) + first = original_Occupant.from_presence(presence, False) Occupant.from_presence.return_value = first self.jmuc._inbound_muc_user_presence(presence) - Occupant.from_presence.assert_called_with(presence) + Occupant.from_presence.assert_called_with(presence, False) self.assertSequenceEqual( self.base.mock_calls, @@ -993,7 +1057,7 @@ def test__inbound_muc_user_presence_emits_on_leave_for_system_shutdown( presence.xep0045_muc_user.status_codes.update({332}) presence.xep0045_muc_user.items[0].reason = "foo" - second = original_Occupant.from_presence(presence) + second = original_Occupant.from_presence(presence, False) Occupant.from_presence.return_value = second self.jmuc._inbound_muc_user_presence(presence) @@ -1027,12 +1091,12 @@ def test__inbound_muc_user_presence_emits_on_status_change(self): original_Occupant = muc_service.Occupant with unittest.mock.patch("aioxmpp.muc.service.Occupant") as Occupant: - first = original_Occupant.from_presence(presence) + first = original_Occupant.from_presence(presence, False) Occupant.from_presence.return_value = first self.jmuc._inbound_muc_user_presence(presence) - Occupant.from_presence.assert_called_with(presence) + Occupant.from_presence.assert_called_with(presence, False) self.assertSequenceEqual( self.base.mock_calls, @@ -1045,7 +1109,7 @@ def test__inbound_muc_user_presence_emits_on_status_change(self): # update presence stanza presence.show = aioxmpp.PresenceShow.AWAY - second = original_Occupant.from_presence(presence) + second = original_Occupant.from_presence(presence, False) Occupant.from_presence.return_value = second self.jmuc._inbound_muc_user_presence(presence) @@ -1081,12 +1145,12 @@ def test__inbound_muc_user_presence_emits_on_nick_change(self): original_Occupant = muc_service.Occupant with unittest.mock.patch("aioxmpp.muc.service.Occupant") as Occupant: - first = original_Occupant.from_presence(presence) + first = original_Occupant.from_presence(presence, False) Occupant.from_presence.return_value = first self.jmuc._inbound_muc_user_presence(presence) - Occupant.from_presence.assert_called_with(presence) + Occupant.from_presence.assert_called_with(presence, False) self.assertSequenceEqual( self.base.mock_calls, @@ -1101,7 +1165,82 @@ def test__inbound_muc_user_presence_emits_on_nick_change(self): presence.xep0045_muc_user.status_codes.add(303) presence.xep0045_muc_user.items[0].nick = "oldhag" - second = original_Occupant.from_presence(presence) + second = original_Occupant.from_presence(presence, False) + Occupant.from_presence.return_value = second + self.jmuc._inbound_muc_user_presence(presence) + + self.assertSequenceEqual( + self.base.mock_calls, + [ + unittest.mock.call.on_nick_change(presence, first) + ] + ) + self.base.mock_calls.clear() + + self.assertEqual( + first.conversation_jid, + TEST_MUC_JID.replace(resource="oldhag"), + ) + + presence = aioxmpp.stanza.Presence( + type_=aioxmpp.structs.PresenceType.AVAILABLE, + from_=TEST_MUC_JID.replace(resource="oldhag") + ) + presence.xep0045_muc_user = muc_xso.UserExt( + items=[ + muc_xso.UserItem(affiliation="member", + role="participant"), + ] + ) + + third = original_Occupant.from_presence(presence, False) + Occupant.from_presence.return_value = third + self.jmuc._inbound_muc_user_presence(presence) + + self.assertSequenceEqual( + self.base.mock_calls, + [ + ] + ) + self.base.mock_calls.clear() + + def test__inbound_muc_self_presence_emits_on_nick_change(self): + presence = aioxmpp.stanza.Presence( + type_=aioxmpp.structs.PresenceType.AVAILABLE, + from_=TEST_MUC_JID.replace(resource="thirdwitch") + ) + presence.xep0045_muc_user = muc_xso.UserExt( + items=[ + muc_xso.UserItem(affiliation="member", + role="participant"), + ], + status_codes={110}, + ) + + original_Occupant = muc_service.Occupant + with unittest.mock.patch("aioxmpp.muc.service.Occupant") as Occupant: + first = original_Occupant.from_presence(presence, True) + Occupant.from_presence.return_value = first + + self.jmuc._inbound_muc_user_presence(presence) + + Occupant.from_presence.assert_called_with(presence, True) + + self.assertSequenceEqual( + self.base.mock_calls, + [ + unittest.mock.call.on_enter(presence, first) + ] + ) + self.base.mock_calls.clear() + + # update presence stanza + presence.type_ = aioxmpp.structs.PresenceType.UNAVAILABLE + presence.xep0045_muc_user.status_codes.add(303) + presence.xep0045_muc_user.status_codes.add(110) + presence.xep0045_muc_user.items[0].nick = "oldhag" + + second = original_Occupant.from_presence(presence, True) Occupant.from_presence.return_value = second self.jmuc._inbound_muc_user_presence(presence) @@ -1114,7 +1253,7 @@ def test__inbound_muc_user_presence_emits_on_nick_change(self): self.base.mock_calls.clear() self.assertEqual( - first.occupantjid, + first.conversation_jid, TEST_MUC_JID.replace(resource="oldhag"), ) @@ -1129,7 +1268,7 @@ def test__inbound_muc_user_presence_emits_on_nick_change(self): ] ) - third = original_Occupant.from_presence(presence) + third = original_Occupant.from_presence(presence, True) Occupant.from_presence.return_value = third self.jmuc._inbound_muc_user_presence(presence) @@ -1154,12 +1293,12 @@ def test__inbound_muc_user_presence_emits_on_various_changes(self): original_Occupant = muc_service.Occupant with unittest.mock.patch("aioxmpp.muc.service.Occupant") as Occupant: - first = original_Occupant.from_presence(presence) + first = original_Occupant.from_presence(presence, False) Occupant.from_presence.return_value = first self.jmuc._inbound_muc_user_presence(presence) - Occupant.from_presence.assert_called_with(presence) + Occupant.from_presence.assert_called_with(presence, False) self.assertSequenceEqual( self.base.mock_calls, @@ -1175,7 +1314,7 @@ def test__inbound_muc_user_presence_emits_on_various_changes(self): presence.xep0045_muc_user.items[0].role = "moderator" presence.xep0045_muc_user.items[0].reason = "foobar" - second = original_Occupant.from_presence(presence) + second = original_Occupant.from_presence(presence, False) Occupant.from_presence.return_value = second self.jmuc._inbound_muc_user_presence(presence) @@ -1426,7 +1565,7 @@ def test__inbound_muc_user_presence_emits_on_enter_and_on_exit(self): self.base.mock_calls, [ unittest.mock.call.on_enter(presence, - self.jmuc.this_occupant), + self.jmuc.me), ] ) self.base.mock_calls.clear() @@ -1434,15 +1573,15 @@ def test__inbound_muc_user_presence_emits_on_enter_and_on_exit(self): self.assertTrue(self.jmuc.joined) self.assertTrue(self.jmuc.active) self.assertIsInstance( - self.jmuc.this_occupant, + self.jmuc.me, muc_service.Occupant ) self.assertEqual( - self.jmuc.this_occupant.occupantjid, + self.jmuc.me.conversation_jid, TEST_MUC_JID.replace(resource="thirdwitch") ) self.assertTrue( - self.jmuc.this_occupant.is_self + self.jmuc.me.is_self ) presence = aioxmpp.stanza.Presence( @@ -1464,7 +1603,7 @@ def test__inbound_muc_user_presence_emits_on_enter_and_on_exit(self): [ unittest.mock.call.on_exit( presence, - self.jmuc.this_occupant, + self.jmuc.me, muc_service.LeaveMode.NORMAL, actor=None, reason=None @@ -1473,15 +1612,15 @@ def test__inbound_muc_user_presence_emits_on_enter_and_on_exit(self): ) self.assertFalse(self.jmuc.joined) self.assertIsInstance( - self.jmuc.this_occupant, + self.jmuc.me, muc_service.Occupant ) self.assertEqual( - self.jmuc.this_occupant.occupantjid, + self.jmuc.me.conversation_jid, TEST_MUC_JID.replace(resource="thirdwitch") ) self.assertTrue( - self.jmuc.this_occupant.is_self + self.jmuc.me.is_self ) self.assertFalse(self.jmuc.active) @@ -1504,7 +1643,7 @@ def test_detect_self_presence_from_jid_if_status_is_missing(self): self.base.mock_calls, [ unittest.mock.call.on_enter(presence, - self.jmuc.this_occupant), + self.jmuc.me), ] ) self.base.mock_calls.clear() @@ -1528,7 +1667,7 @@ def test_detect_self_presence_from_jid_if_status_is_missing(self): [ unittest.mock.call.on_exit( presence, - self.jmuc.this_occupant, + self.jmuc.me, muc_service.LeaveMode.KICKED, actor=None, reason=None @@ -1537,15 +1676,15 @@ def test_detect_self_presence_from_jid_if_status_is_missing(self): ) self.assertFalse(self.jmuc.joined) self.assertIsInstance( - self.jmuc.this_occupant, + self.jmuc.me, muc_service.Occupant ) self.assertEqual( - self.jmuc.this_occupant.occupantjid, + self.jmuc.me.conversation_jid, TEST_MUC_JID.replace(resource="thirdwitch") ) self.assertTrue( - self.jmuc.this_occupant.is_self + self.jmuc.me.is_self ) self.assertFalse(self.jmuc.active) @@ -1568,7 +1707,7 @@ def test_do_not_treat_unavailable_stanzas_as_join(self): self.base.mock_calls, [ unittest.mock.call.on_enter(presence, - self.jmuc.this_occupant), + self.jmuc.me), ] ) self.base.mock_calls.clear() @@ -1616,7 +1755,7 @@ def test__inbound_muc_user_presence_ignores_self_leave_if_inactive(self): self.assertFalse(self.jmuc.joined) self.assertFalse(self.jmuc.active) - self.assertIsNone(self.jmuc.this_occupant) + self.assertIsNone(self.jmuc.me) def test_set_role(self): new_role = "participant" @@ -1842,8 +1981,7 @@ def test_leave_and_wait(self): self.jmuc.on_exit(object(), object(), object()) - - def test_occupants(self): + def test_members(self): presence = aioxmpp.stanza.Presence( type_=aioxmpp.structs.PresenceType.AVAILABLE, from_=TEST_MUC_JID.replace(resource="firstwitch") @@ -1870,14 +2008,14 @@ def test_occupants(self): ) self.jmuc._inbound_muc_user_presence(presence) - occupants = [ + members = [ occupant for _, (_, occupant, *_), _ in self.base.on_join.mock_calls ] self.assertSetEqual( - set(occupants), - set(self.jmuc.occupants) + set(members), + set(self.jmuc.members) ) presence = aioxmpp.stanza.Presence( @@ -1893,17 +2031,17 @@ def test_occupants(self): ) self.jmuc._inbound_muc_user_presence(presence) - occupants += [ + members += [ occupant for _, (_, occupant, *_), _ in self.base.on_enter.mock_calls ] self.assertSetEqual( - set(occupants), - set(self.jmuc.occupants) + set(members), + set(self.jmuc.members) ) - self.assertIs(self.jmuc.occupants[0], self.jmuc.this_occupant) + self.assertIs(self.jmuc.members[0], self.jmuc.me) def test_request_voice(self): run_coroutine(self.jmuc.request_voice()) @@ -2864,7 +3002,7 @@ def test_stream_destruction_without_autorejoin(self): unittest.mock.call.enter1(unittest.mock.ANY, unittest.mock.ANY), unittest.mock.call.exit1(None, - room1.this_occupant, + room1.me, muc_service.LeaveMode.DISCONNECTED), ] ) From fabcc9cf3ba0b2204f458c02ff1cdc0ee6bd666b Mon Sep 17 00:00:00 2001 From: Jonas Wielicki Date: Tue, 4 Apr 2017 16:23:04 +0200 Subject: [PATCH 17/40] im-muc: Simplify registration of mock handlers to MUC signals in tests --- tests/muc/test_service.py | 44 +++++++-------------------------------- 1 file changed, 8 insertions(+), 36 deletions(-) diff --git a/tests/muc/test_service.py b/tests/muc/test_service.py index f848619e..5d60b6d7 100644 --- a/tests/muc/test_service.py +++ b/tests/muc/test_service.py @@ -286,42 +286,14 @@ def setUp(self): self.jmuc = muc_service.Room(self.base.service, self.mucjid) - # this occupant state events - self.base.on_enter.return_value = None - self.base.on_exit.return_value = None - self.base.on_suspend.return_value = None - self.base.on_resume.return_value = None - - self.jmuc.on_enter.connect(self.base.on_enter) - self.jmuc.on_exit.connect(self.base.on_exit) - self.jmuc.on_suspend.connect(self.base.on_suspend) - self.jmuc.on_resume.connect(self.base.on_resume) - - # messaging events - self.base.on_message.return_value = None - - self.jmuc.on_message.connect(self.base.on_message) - - # room meta events - self.base.on_subject_change.return_value = None - - self.jmuc.on_subject_change.connect(self.base.on_subject_change) - - # other occupant presence/permission events - self.base.on_join.return_value = None - self.base.on_status_change.return_value = None - self.base.on_nick_change.return_value = None - self.base.on_role_change.return_value = None - self.base.on_affiliation_change.return_value = None - self.base.on_leave.return_value = None - - self.jmuc.on_join.connect(self.base.on_join) - self.jmuc.on_status_change.connect(self.base.on_status_change) - self.jmuc.on_nick_change.connect(self.base.on_nick_change) - self.jmuc.on_role_change.connect(self.base.on_role_change) - self.jmuc.on_affiliation_change.connect( - self.base.on_affiliation_change) - self.jmuc.on_leave.connect(self.base.on_leave) + for ev in ["on_enter", "on_exit", "on_suspend", "on_resume", + "on_message", "on_subject_change", + "on_join", "on_status_change", "on_nick_change", + "on_role_change", "on_affiliation_change", + "on_leave"]: + cb = getattr(self.base, ev) + cb.return_value = None + getattr(self.jmuc, ev).connect(cb) def tearDown(self): del self.jmuc From c05d2436a55faed890decb118ba15cbf493d9c89 Mon Sep 17 00:00:00 2001 From: Jonas Wielicki Date: Tue, 4 Apr 2017 16:33:40 +0200 Subject: [PATCH 18/40] im-muc: Implement on_presence_changed event on MUC Room --- aioxmpp/muc/service.py | 26 +++++++++++++------------- tests/muc/test_service.py | 18 ++++++++++++------ 2 files changed, 25 insertions(+), 19 deletions(-) diff --git a/aioxmpp/muc/service.py b/aioxmpp/muc/service.py index 76a0f32b..7afa1f09 100644 --- a/aioxmpp/muc/service.py +++ b/aioxmpp/muc/service.py @@ -381,14 +381,6 @@ class Room(aioxmpp.im.conversation.AbstractConversation): There may be `actor` and/or `reason` keyword arguments which provide details on who triggered the change in role and for what reason. - .. signal:: on_status_change(presence, occupant, **kwargs) - - Emits when the presence state and/or status of an `occupant` in the room - changes. - - `occupant` is the :class:`Occupant` instance tracking the occupant whose - status changed. - .. signal:: on_nick_change(presence, occupant, **kwargs) Emits when the nick name (room name) of an `occupant` changes. @@ -409,7 +401,7 @@ class Room(aioxmpp.im.conversation.AbstractConversation): # other occupant state events on_join = aioxmpp.callbacks.Signal() on_leave = aioxmpp.callbacks.Signal() - on_status_change = aioxmpp.callbacks.Signal() + on_presence_changed = aioxmpp.callbacks.Signal() on_affiliation_change = aioxmpp.callbacks.Signal() on_nick_change = aioxmpp.callbacks.Signal() on_role_change = aioxmpp.callbacks.Signal() @@ -606,12 +598,17 @@ def _diff_presence(self, stanza, info, existing): ) elif (existing.presence_state != info.presence_state or existing.presence_status != info.presence_status): - to_emit.append((self.on_status_change, (), {})) + to_emit.append((self.on_presence_changed, + (existing, None, stanza), + {})) if existing.role != info.role: to_emit.append(( self.on_role_change, - (), + ( + stanza, + existing, + ), { "actor": stanza.xep0045_muc_user.items[0].actor, "reason": stanza.xep0045_muc_user.items[0].reason, @@ -621,7 +618,10 @@ def _diff_presence(self, stanza, info, existing): if existing.affiliation != info.affiliation: to_emit.append(( self.on_affiliation_change, - (), + ( + stanza, + existing, + ), { "actor": stanza.xep0045_muc_user.items[0].actor, "reason": stanza.xep0045_muc_user.items[0].reason, @@ -631,7 +631,7 @@ def _diff_presence(self, stanza, info, existing): if to_emit: existing.update(info) for signal, args, kwargs in to_emit: - signal(stanza, existing, *args, **kwargs) + signal(*args, **kwargs) return result diff --git a/tests/muc/test_service.py b/tests/muc/test_service.py index 5d60b6d7..0144dc69 100644 --- a/tests/muc/test_service.py +++ b/tests/muc/test_service.py @@ -288,7 +288,7 @@ def setUp(self): for ev in ["on_enter", "on_exit", "on_suspend", "on_resume", "on_message", "on_subject_change", - "on_join", "on_status_change", "on_nick_change", + "on_join", "on_presence_changed", "on_nick_change", "on_role_change", "on_affiliation_change", "on_leave"]: cb = getattr(self.base, ev) @@ -316,7 +316,7 @@ def test_event_attributes(self): aioxmpp.callbacks.AdHocSignal ) self.assertIsInstance( - self.jmuc.on_status_change, + self.jmuc.on_presence_changed, aioxmpp.callbacks.AdHocSignal ) self.assertIsInstance( @@ -1049,7 +1049,7 @@ def test__inbound_muc_user_presence_emits_on_leave_for_system_shutdown( "none" ) - def test__inbound_muc_user_presence_emits_on_status_change(self): + def test__inbound_muc_user_presence_emits_on_presence_changed(self): presence = aioxmpp.stanza.Presence( type_=aioxmpp.structs.PresenceType.AVAILABLE, from_=TEST_MUC_JID.replace(resource="firstwitch") @@ -1088,9 +1088,11 @@ def test__inbound_muc_user_presence_emits_on_status_change(self): self.assertSequenceEqual( self.base.mock_calls, [ - unittest.mock.call.on_status_change( + unittest.mock.call.on_presence_changed( + first, + None, presence, - first) + ) ] ) @@ -1293,7 +1295,11 @@ def test__inbound_muc_user_presence_emits_on_various_changes(self): self.assertSequenceEqual( self.base.mock_calls, [ - unittest.mock.call.on_status_change(presence, first), + unittest.mock.call.on_presence_changed( + first, + None, + presence, + ), unittest.mock.call.on_role_change( presence, first, actor=None, From c2b0cce83365f00153c5ea13e6517eb1ce9f6469 Mon Sep 17 00:00:00 2001 From: Jonas Wielicki Date: Wed, 5 Apr 2017 14:45:29 +0200 Subject: [PATCH 19/40] im-muc: Port message and topic events to AbstractConversation interface --- aioxmpp/im/conversation.py | 9 +++ aioxmpp/muc/service.py | 42 +++++--------- tests/muc/test_e2e.py | 21 ++++--- tests/muc/test_service.py | 116 ++++++++++++++++++------------------- 4 files changed, 95 insertions(+), 93 deletions(-) diff --git a/aioxmpp/im/conversation.py b/aioxmpp/im/conversation.py index fb8a08f8..441f789f 100644 --- a/aioxmpp/im/conversation.py +++ b/aioxmpp/im/conversation.py @@ -368,6 +368,15 @@ class AbstractConversation(metaclass=abc.ABCMeta): where it is not trivial for the protocol to actually determine the old nickname or where no nickname was set before. + .. signal:: on_topic_changed(member, new_topic, **kwargs) + + The topic of the conversation has changed. + + :param member: The member object who changed the topic. + :type member: :class:`~.AbstractConversationMember` + :param new_topic: The new topic of the conversation. + :type new_topic: :class:`str` + .. signal:: on_join(member, **kwargs) A new member has joined the conversation. diff --git a/aioxmpp/muc/service.py b/aioxmpp/muc/service.py index 7afa1f09..e76a4a38 100644 --- a/aioxmpp/muc/service.py +++ b/aioxmpp/muc/service.py @@ -407,7 +407,7 @@ class Room(aioxmpp.im.conversation.AbstractConversation): on_role_change = aioxmpp.callbacks.Signal() # room state events - on_subject_change = aioxmpp.callbacks.Signal() + on_topic_changed = aioxmpp.callbacks.Signal() def __init__(self, service, mucjid): super().__init__(service) @@ -520,43 +520,29 @@ def _resume(self): self.on_resume() def _handle_message(self, message, peer, sent, source): - if not sent: - self._inbound_message(message) - - def _inbound_message(self, stanza): self._service.logger.debug("%s: inbound message %r", self._mucjid, - stanza) + message) - if not stanza.body and stanza.subject: - self._subject = aioxmpp.structs.LanguageMap(stanza.subject) - self._subject_setter = stanza.from_.resource + if not message.body and message.subject: + self._subject = aioxmpp.structs.LanguageMap(message.subject) + self._subject_setter = message.from_.resource - self.on_subject_change( - stanza, + self.on_topic_changed( + self._occupant_info.get(message.from_, None), self._subject, - occupant=self._occupant_info.get(stanza.from_, None) ) - elif stanza.body: + + elif message.body: self.on_message( - stanza, - occupant=self._occupant_info.get(stanza.from_, None) + message, + self._occupant_info.get(message.from_, None), + source, ) - try: - tracker = self._tracking.pop(stanza.id_) - except KeyError: - pass - else: - try: - tracker.state = \ - aioxmpp.tracking.MessageState.DELIVERED_TO_RECIPIENT - except ValueError: - pass - def _diff_presence(self, stanza, info, existing): - if (not info.presence_state.available and - 303 in stanza.xep0045_muc_user.status_codes): + if (not info.presence_state.available and + 303 in stanza.xep0045_muc_user.status_codes): return ( _OccupantDiffClass.NICK_CHANGED, ( diff --git a/tests/muc/test_e2e.py b/tests/muc/test_e2e.py index 98087fa1..7bbe3b24 100644 --- a/tests/muc/test_e2e.py +++ b/tests/muc/test_e2e.py @@ -215,16 +215,16 @@ def onleave(presence, occupant, mode, **kwargs): def test_set_subject(self): subject_fut = asyncio.Future() - def onsubject(message, subject, **kwargs): + def onsubject(member, subject, **kwargs): nonlocal subject_fut - subject_fut.set_result((message, subject)) + subject_fut.set_result((member, subject)) return True - self.secondroom.on_subject_change.connect(onsubject) + self.secondroom.on_topic_changed.connect(onsubject) self.firstroom.set_subject({None: "Wytches Brew!"}) - message, subject = yield from subject_fut + member, subject = yield from subject_fut self.assertDictEqual( subject, @@ -279,9 +279,9 @@ def onstatechange(state): def test_send_message(self): msg_future = asyncio.Future() - def onmessage(message, **kwargs): + def onmessage(message, member, source, **kwargs): nonlocal msg_future - msg_future.set_result((message,)) + msg_future.set_result((message, member,)) return True self.secondroom.on_message.connect(onmessage) @@ -293,7 +293,7 @@ def onmessage(message, **kwargs): msg.body[None] = "foo" yield from self.firstwitch.stream.send(msg) - message, = yield from msg_future + message, member, = yield from msg_future self.assertDictEqual( message.body, { @@ -301,6 +301,13 @@ def onmessage(message, **kwargs): } ) + self.assertCountEqual( + [member], + [member + for member in self.secondroom.members + if member.nick == "firstwitch"], + ) + @blocking_timed @asyncio.coroutine def test_change_nick(self): diff --git a/tests/muc/test_service.py b/tests/muc/test_service.py index 0144dc69..9a0ee3d3 100644 --- a/tests/muc/test_service.py +++ b/tests/muc/test_service.py @@ -287,7 +287,7 @@ def setUp(self): self.jmuc = muc_service.Room(self.base.service, self.mucjid) for ev in ["on_enter", "on_exit", "on_suspend", "on_resume", - "on_message", "on_subject_change", + "on_message", "on_topic_changed", "on_join", "on_presence_changed", "on_nick_change", "on_role_change", "on_affiliation_change", "on_leave"]: @@ -332,7 +332,7 @@ def test_event_attributes(self): aioxmpp.callbacks.AdHocSignal ) self.assertIsInstance( - self.jmuc.on_subject_change, + self.jmuc.on_topic_changed, aioxmpp.callbacks.AdHocSignal ) self.assertIsInstance( @@ -1328,35 +1328,7 @@ def test__inbound_muc_user_presence_emits_on_various_changes(self): "moderator" ) - def test__handle_message_calls_inbound_for_received(self): - with unittest.mock.patch.object( - self.jmuc, - "_inbound_message") as _inbound_message: - self.jmuc._handle_message( - unittest.mock.sentinel.msg, - unittest.mock.sentinel.peer, - False, - unittest.mock.sentinel.source, - ) - - _inbound_message.assert_called_once_with( - unittest.mock.sentinel.msg - ) - - def test__handle_message_does_not_call_inbound_for_sent(self): - with unittest.mock.patch.object( - self.jmuc, - "_inbound_message") as _inbound_message: - self.jmuc._handle_message( - unittest.mock.sentinel.msg, - unittest.mock.sentinel.peer, - True, - unittest.mock.sentinel.source, - ) - - _inbound_message.assert_not_called() - - def test__inbound_message_handles_subject_of_occupant(self): + def test_handle_message_handles_subject_of_occupant(self): pres = aioxmpp.stanza.Presence( from_=TEST_MUC_JID.replace(resource="secondwitch"), ) @@ -1377,7 +1349,12 @@ def test__inbound_message_handles_subject_of_occupant(self): old_subject = self.jmuc.subject - self.jmuc._inbound_message(msg) + self.jmuc._handle_message( + msg, + msg.from_, + False, + unittest.mock.sentinel.source, + ) self.assertDictEqual( self.jmuc.subject, @@ -1390,15 +1367,16 @@ def test__inbound_message_handles_subject_of_occupant(self): self.assertSequenceEqual( self.base.mock_calls, [ - unittest.mock.call.on_subject_change( - msg, - self.jmuc.subject, - occupant=occupant + unittest.mock.call.on_topic_changed( + occupant, + { + None: "foo", + } ) ] ) - def test__inbound_message_handles_subject_of_non_occupant(self): + def test_handle_message_handles_subject_of_non_occupant(self): msg = aioxmpp.stanza.Message( from_=TEST_MUC_JID.replace(resource="secondwitch"), type_=aioxmpp.structs.MessageType.GROUPCHAT, @@ -1409,7 +1387,12 @@ def test__inbound_message_handles_subject_of_non_occupant(self): old_subject = self.jmuc.subject - self.jmuc._inbound_message(msg) + self.jmuc._handle_message( + msg, + msg.from_, + False, + unittest.mock.sentinel.source, + ) self.assertDictEqual( self.jmuc.subject, @@ -1422,15 +1405,14 @@ def test__inbound_message_handles_subject_of_non_occupant(self): self.assertSequenceEqual( self.base.mock_calls, [ - unittest.mock.call.on_subject_change( - msg, - self.jmuc.subject, - occupant=None + unittest.mock.call.on_topic_changed( + None, + msg.subject, ) ] ) - def test__inbound_message_ignores_subject_if_body_is_present(self): + def test_handle_message_ignores_subject_if_body_is_present(self): msg = aioxmpp.stanza.Message( from_=TEST_MUC_JID.replace(resource="secondwitch"), type_=aioxmpp.structs.MessageType.GROUPCHAT, @@ -1442,7 +1424,12 @@ def test__inbound_message_ignores_subject_if_body_is_present(self): aioxmpp.structs.LanguageTag.fromstr("de"): "bar" }) - self.jmuc._inbound_message(msg) + self.jmuc._handle_message( + msg, + msg.from_, + False, + unittest.mock.sentinel.source + ) self.assertDictEqual( self.jmuc.subject, @@ -1450,9 +1437,9 @@ def test__inbound_message_ignores_subject_if_body_is_present(self): ) self.assertIsNone(self.jmuc.subject_setter) - self.assertFalse(self.base.on_subject_change.mock_calls) + self.base.on_topic_changed.assert_not_called() - def test__inbound_message_does_not_reset_subject_if_no_subject_given(self): + def test_handle_message_does_not_reset_subject_if_no_subject_given(self): self.jmuc.subject[None] = "foo" msg = aioxmpp.stanza.Message( @@ -1460,7 +1447,12 @@ def test__inbound_message_does_not_reset_subject_if_no_subject_given(self): type_=aioxmpp.structs.MessageType.GROUPCHAT, ) - self.jmuc._inbound_message(msg) + self.jmuc._handle_message( + msg, + msg.from_, + False, + unittest.mock.sentinel.source + ) self.assertDictEqual( self.jmuc.subject, @@ -1470,32 +1462,34 @@ def test__inbound_message_does_not_reset_subject_if_no_subject_given(self): ) self.assertIsNone(self.jmuc.subject_setter) - self.assertSequenceEqual( - self.base.mock_calls, - [ - ] - ) + self.base.on_topic_changed.assert_not_called() - def test__inbound_groupchat_message_with_body_emits_on_message(self): + def test_inbound_groupchat_message_with_body_emits_on_message(self): msg = aioxmpp.stanza.Message( from_=TEST_MUC_JID.replace(resource="secondwitch"), type_=aioxmpp.structs.MessageType.GROUPCHAT, ) msg.body[None] = "foo" - self.jmuc._inbound_message(msg) + self.jmuc._handle_message( + msg, + msg.from_, + False, + unittest.mock.sentinel.source, + ) self.assertSequenceEqual( self.base.mock_calls, [ unittest.mock.call.on_message( msg, - occupant=None + None, + unittest.mock.sentinel.source, ) ] ) - def test__inbound_groupchat_message_with_body_emits_on_message_with_occupant(self): + def test_inbound_groupchat_message_with_body_emits_on_message_with_member(self): pres = aioxmpp.stanza.Presence( from_=TEST_MUC_JID.replace(resource="secondwitch"), ) @@ -1512,14 +1506,20 @@ def test__inbound_groupchat_message_with_body_emits_on_message_with_occupant(sel ) msg.body[None] = "foo" - self.jmuc._inbound_message(msg) + self.jmuc._handle_message( + msg, + msg.from_, + False, + unittest.mock.sentinel.source, + ) self.assertSequenceEqual( self.base.mock_calls, [ unittest.mock.call.on_message( msg, - occupant=occupant + self.jmuc.members[0], + unittest.mock.sentinel.source, ) ] ) From 6731a318d24e2a114976f47b85dff8f994479c00 Mon Sep 17 00:00:00 2001 From: Jonas Wielicki Date: Wed, 5 Apr 2017 14:49:24 +0200 Subject: [PATCH 20/40] im-muc: Port on_join event to AbstractConversation interface --- aioxmpp/muc/service.py | 2 +- tests/muc/test_e2e.py | 11 +++-------- tests/muc/test_service.py | 25 ++++++++++++------------- 3 files changed, 16 insertions(+), 22 deletions(-) diff --git a/aioxmpp/muc/service.py b/aioxmpp/muc/service.py index e76a4a38..4eea7132 100644 --- a/aioxmpp/muc/service.py +++ b/aioxmpp/muc/service.py @@ -688,7 +688,7 @@ def _inbound_muc_user_presence(self, stanza): ) return self._occupant_info[info.conversation_jid] = info - self.on_join(stanza, info) + self.on_join(info) return mode, data = self._diff_presence(stanza, info, existing) diff --git a/tests/muc/test_e2e.py b/tests/muc/test_e2e.py index 7bbe3b24..b62b7743 100644 --- a/tests/muc/test_e2e.py +++ b/tests/muc/test_e2e.py @@ -92,11 +92,11 @@ def test_join(self): recvd_future = asyncio.Future() - def onjoin(presence, occupant, **kwargs): + def onjoin(occupant, **kwargs): if occupant.nick != "thirdwitch": return nonlocal recvd_future - recvd_future.set_result((presence, occupant)) + recvd_future.set_result((occupant, )) # we do not want to be called again return True @@ -105,17 +105,12 @@ def onjoin(presence, occupant, **kwargs): thirdroom, fut = service.join(self.mucjid, "thirdwitch") yield from fut - presence, occupant = yield from recvd_future + occupant, = yield from recvd_future self.assertEqual( occupant.conversation_jid, self.mucjid.replace(resource="thirdwitch"), ) - self.assertEqual( - presence.from_, - occupant.conversation_jid, - ) - @blocking_timed @asyncio.coroutine def test_kick(self): diff --git a/tests/muc/test_service.py b/tests/muc/test_service.py index 9a0ee3d3..6b9e4b46 100644 --- a/tests/muc/test_service.py +++ b/tests/muc/test_service.py @@ -629,7 +629,6 @@ def test__inbound_muc_user_presence_emits_on_join_for_new_users(self): self.base.mock_calls, [ unittest.mock.call.on_join( - presence, Occupant.from_presence() ) ] @@ -674,7 +673,7 @@ def test__inbound_muc_user_presence_emits_on_leave_for_unavailable(self): self.assertSequenceEqual( self.base.mock_calls, [ - unittest.mock.call.on_join(presence, first) + unittest.mock.call.on_join(first) ] ) self.base.mock_calls.clear() @@ -733,7 +732,7 @@ def test__inbound_muc_user_presence_emits_on_leave_for_kick(self): self.assertSequenceEqual( self.base.mock_calls, [ - unittest.mock.call.on_join(presence, first) + unittest.mock.call.on_join(first) ] ) self.base.mock_calls.clear() @@ -806,7 +805,7 @@ def test__inbound_muc_user_presence_emits_on_leave_for_ban(self): self.assertSequenceEqual( self.base.mock_calls, [ - unittest.mock.call.on_join(presence, first) + unittest.mock.call.on_join(first) ] ) self.base.mock_calls.clear() @@ -880,7 +879,7 @@ def test__inbound_muc_user_presence_emits_on_leave_for_affiliation_change( self.assertSequenceEqual( self.base.mock_calls, [ - unittest.mock.call.on_join(presence, first) + unittest.mock.call.on_join(first) ] ) self.base.mock_calls.clear() @@ -955,7 +954,7 @@ def test__inbound_muc_user_presence_emits_on_leave_for_moderation_change( self.assertSequenceEqual( self.base.mock_calls, [ - unittest.mock.call.on_join(presence, first) + unittest.mock.call.on_join(first) ] ) self.base.mock_calls.clear() @@ -1019,7 +1018,7 @@ def test__inbound_muc_user_presence_emits_on_leave_for_system_shutdown( self.assertSequenceEqual( self.base.mock_calls, [ - unittest.mock.call.on_join(presence, first) + unittest.mock.call.on_join(first) ] ) self.base.mock_calls.clear() @@ -1073,7 +1072,7 @@ def test__inbound_muc_user_presence_emits_on_presence_changed(self): self.assertSequenceEqual( self.base.mock_calls, [ - unittest.mock.call.on_join(presence, first) + unittest.mock.call.on_join(first) ] ) self.base.mock_calls.clear() @@ -1129,7 +1128,7 @@ def test__inbound_muc_user_presence_emits_on_nick_change(self): self.assertSequenceEqual( self.base.mock_calls, [ - unittest.mock.call.on_join(presence, first) + unittest.mock.call.on_join(first) ] ) self.base.mock_calls.clear() @@ -1277,7 +1276,7 @@ def test__inbound_muc_user_presence_emits_on_various_changes(self): self.assertSequenceEqual( self.base.mock_calls, [ - unittest.mock.call.on_join(presence, first) + unittest.mock.call.on_join(first) ] ) self.base.mock_calls.clear() @@ -1336,7 +1335,7 @@ def test_handle_message_handles_subject_of_occupant(self): self.jmuc._inbound_muc_user_presence(pres) - _, (_, occupant), _ = self.base.on_join.mock_calls[-1] + _, (occupant, ), _ = self.base.on_join.mock_calls[-1] self.base.mock_calls.clear() msg = aioxmpp.stanza.Message( @@ -1497,7 +1496,7 @@ def test_inbound_groupchat_message_with_body_emits_on_message_with_member(self): self.jmuc._inbound_muc_user_presence(pres) - _, (_, occupant), _ = self.base.on_join.mock_calls[-1] + _, (occupant, ), _ = self.base.on_join.mock_calls[-1] self.base.mock_calls.clear() msg = aioxmpp.stanza.Message( @@ -1988,7 +1987,7 @@ def test_members(self): members = [ occupant - for _, (_, occupant, *_), _ in self.base.on_join.mock_calls + for _, (occupant, *_), _ in self.base.on_join.mock_calls ] self.assertSetEqual( From 8d7f9385cefa9215c08b65c6c2af8f9aa73d73ee Mon Sep 17 00:00:00 2001 From: Jonas Wielicki Date: Wed, 5 Apr 2017 14:54:37 +0200 Subject: [PATCH 21/40] im-muc: Port on_leave event to AbstractConversation interface --- aioxmpp/muc/service.py | 5 ++++- tests/muc/test_e2e.py | 34 ++++++++++++++++++++++++++++--- tests/muc/test_service.py | 42 +++++++++++++++++---------------------- 3 files changed, 53 insertions(+), 28 deletions(-) diff --git a/aioxmpp/muc/service.py b/aioxmpp/muc/service.py index 4eea7132..77b03448 100644 --- a/aioxmpp/muc/service.py +++ b/aioxmpp/muc/service.py @@ -703,7 +703,10 @@ def _inbound_muc_user_presence(self, stanza): elif mode == _OccupantDiffClass.LEFT: mode, actor, reason = data existing.update(info) - self.on_leave(stanza, existing, mode, actor=actor, reason=reason) + self.on_leave(existing, + muc_leave_mode=mode, + muc_actor=actor, + muc_reason=reason) del self._occupant_info[existing.conversation_jid] @asyncio.coroutine diff --git a/tests/muc/test_e2e.py b/tests/muc/test_e2e.py index b62b7743..f0ac9240 100644 --- a/tests/muc/test_e2e.py +++ b/tests/muc/test_e2e.py @@ -115,13 +115,20 @@ def onjoin(occupant, **kwargs): @asyncio.coroutine def test_kick(self): exit_fut = asyncio.Future() + leave_fut = asyncio.Future() def onexit(presence, occupant, mode, **kwargs): nonlocal exit_fut exit_fut.set_result((presence, occupant, mode)) return True + def onleave(occupant, muc_leave_mode, **kwargs): + nonlocal leave_fut + leave_fut.set_result((occupant, muc_leave_mode)) + return True + self.secondroom.on_exit.connect(onexit) + self.firstroom.on_leave.connect(onleave) yield from self.firstroom.set_role( "secondwitch", @@ -140,17 +147,31 @@ def onexit(presence, occupant, mode, **kwargs): aioxmpp.muc.LeaveMode.KICKED, ) + occupant, mode = yield from leave_fut + + self.assertEqual( + mode, + aioxmpp.muc.LeaveMode.KICKED, + ) + @blocking_timed @asyncio.coroutine def test_ban(self): exit_fut = asyncio.Future() + leave_fut = asyncio.Future() def onexit(presence, occupant, mode, **kwargs): nonlocal exit_fut exit_fut.set_result((presence, occupant, mode)) return True + def onleave(occupant, muc_leave_mode, **kwargs): + nonlocal leave_fut + leave_fut.set_result((occupant, muc_leave_mode)) + return True + self.secondroom.on_exit.connect(onexit) + self.firstroom.on_leave.connect(onleave) yield from self.firstroom.set_affiliation( self.secondwitch.local_jid.bare(), @@ -169,6 +190,13 @@ def onexit(presence, occupant, mode, **kwargs): aioxmpp.muc.LeaveMode.BANNED, ) + occupant, mode = yield from leave_fut + + self.assertEqual( + mode, + aioxmpp.muc.LeaveMode.BANNED, + ) + @blocking_timed @asyncio.coroutine def test_leave(self): @@ -180,9 +208,9 @@ def onexit(presence, occupant, mode, **kwargs): exit_fut.set_result((presence, occupant, mode)) return True - def onleave(presence, occupant, mode, **kwargs): + def onleave(occupant, muc_leave_mode, **kwargs): nonlocal leave_fut - leave_fut.set_result((presence, occupant, mode)) + leave_fut.set_result((occupant, muc_leave_mode)) return True self.firstroom.on_leave.connect(onleave) @@ -199,7 +227,7 @@ def onleave(presence, occupant, mode, **kwargs): aioxmpp.muc.LeaveMode.NORMAL, ) - presence, occupant, mode = yield from leave_fut + occupant, mode = yield from leave_fut self.assertEqual( mode, aioxmpp.muc.LeaveMode.NORMAL, diff --git a/tests/muc/test_service.py b/tests/muc/test_service.py index 6b9e4b46..d7bb6b44 100644 --- a/tests/muc/test_service.py +++ b/tests/muc/test_service.py @@ -692,11 +692,10 @@ def test__inbound_muc_user_presence_emits_on_leave_for_unavailable(self): self.base.mock_calls, [ unittest.mock.call.on_leave( - presence, first, - muc_service.LeaveMode.NORMAL, - actor=None, - reason=None) + muc_leave_mode=muc_service.LeaveMode.NORMAL, + muc_actor=None, + muc_reason=None) ] ) @@ -760,11 +759,10 @@ def test__inbound_muc_user_presence_emits_on_leave_for_kick(self): actor=actor, reason="Avaunt, you cullion!"), unittest.mock.call.on_leave( - presence, first, - muc_service.LeaveMode.KICKED, - actor=actor, - reason="Avaunt, you cullion!") + muc_leave_mode=muc_service.LeaveMode.KICKED, + muc_actor=actor, + muc_reason="Avaunt, you cullion!") ] ) @@ -841,11 +839,10 @@ def test__inbound_muc_user_presence_emits_on_leave_for_ban(self): reason="Treason" ), unittest.mock.call.on_leave( - presence, first, - muc_service.LeaveMode.BANNED, - actor=actor, - reason="Treason") + muc_leave_mode=muc_service.LeaveMode.BANNED, + muc_actor=actor, + muc_reason="Treason") ] ) self.assertEqual( @@ -912,11 +909,10 @@ def test__inbound_muc_user_presence_emits_on_leave_for_affiliation_change( reason="foo" ), unittest.mock.call.on_leave( - presence, first, - muc_service.LeaveMode.AFFILIATION_CHANGE, - actor=actor, - reason="foo") + muc_leave_mode=muc_service.LeaveMode.AFFILIATION_CHANGE, + muc_actor=actor, + muc_reason="foo") ] ) self.assertEqual( @@ -981,11 +977,10 @@ def test__inbound_muc_user_presence_emits_on_leave_for_moderation_change( reason="foo", ), unittest.mock.call.on_leave( - presence, first, - muc_service.LeaveMode.MODERATION_CHANGE, - actor=actor, - reason="foo") + muc_leave_mode=muc_service.LeaveMode.MODERATION_CHANGE, + muc_actor=actor, + muc_reason="foo") ] ) self.assertEqual( @@ -1036,11 +1031,10 @@ def test__inbound_muc_user_presence_emits_on_leave_for_system_shutdown( self.base.mock_calls, [ unittest.mock.call.on_leave( - presence, first, - muc_service.LeaveMode.SYSTEM_SHUTDOWN, - actor=None, - reason="foo") + muc_leave_mode=muc_service.LeaveMode.SYSTEM_SHUTDOWN, + muc_actor=None, + muc_reason="foo") ] ) self.assertEqual( From 24f4f96ece06a2da8f1b5feb8d932370d9c27edd Mon Sep 17 00:00:00 2001 From: Jonas Wielicki Date: Wed, 5 Apr 2017 14:59:20 +0200 Subject: [PATCH 22/40] im-muc: Port on_nick_changed event to AbstractConversation interface --- aioxmpp/muc/service.py | 10 ++++++---- tests/muc/test_e2e.py | 16 ++++++++++------ tests/muc/test_service.py | 20 ++++++++++++++------ 3 files changed, 30 insertions(+), 16 deletions(-) diff --git a/aioxmpp/muc/service.py b/aioxmpp/muc/service.py index 77b03448..75d51b49 100644 --- a/aioxmpp/muc/service.py +++ b/aioxmpp/muc/service.py @@ -403,7 +403,7 @@ class Room(aioxmpp.im.conversation.AbstractConversation): on_leave = aioxmpp.callbacks.Signal() on_presence_changed = aioxmpp.callbacks.Signal() on_affiliation_change = aioxmpp.callbacks.Signal() - on_nick_change = aioxmpp.callbacks.Signal() + on_nick_changed = aioxmpp.callbacks.Signal() on_role_change = aioxmpp.callbacks.Signal() # room state events @@ -645,14 +645,15 @@ def _handle_self_presence(self, stanza): mode, data = self._diff_presence(stanza, info, existing) if mode == _OccupantDiffClass.NICK_CHANGED: new_nick, = data + old_nick = existing.nick self._service.logger.debug("%s: nick changed: %r -> %r", self._mucjid, - existing.conversation_jid.resource, + old_nick, new_nick) existing._conversation_jid = existing.conversation_jid.replace( resource=new_nick ) - self.on_nick_change(stanza, existing) + self.on_nick_changed(existing, old_nick, new_nick) elif mode == _OccupantDiffClass.LEFT: mode, actor, reason = data self._service.logger.debug("%s: we left the MUC. reason=%r", @@ -694,12 +695,13 @@ def _inbound_muc_user_presence(self, stanza): mode, data = self._diff_presence(stanza, info, existing) if mode == _OccupantDiffClass.NICK_CHANGED: new_nick, = data + old_nick = existing.nick del self._occupant_info[existing.conversation_jid] existing._conversation_jid = existing.conversation_jid.replace( resource=new_nick ) self._occupant_info[existing.conversation_jid] = existing - self.on_nick_change(stanza, existing) + self.on_nick_changed(existing, old_nick, new_nick) elif mode == _OccupantDiffClass.LEFT: mode, actor, reason = data existing.update(info) diff --git a/tests/muc/test_e2e.py b/tests/muc/test_e2e.py index f0ac9240..b41b0249 100644 --- a/tests/muc/test_e2e.py +++ b/tests/muc/test_e2e.py @@ -337,23 +337,27 @@ def test_change_nick(self): self_future = asyncio.Future() foreign_future = asyncio.Future() - def onnickchange(fut, presence, occupant, **kwargs): - fut.set_result((presence, occupant,)) + def onnickchange(fut, occupant, old_nick, new_nick, **kwargs): + fut.set_result((occupant, old_nick, new_nick)) return True - self.secondroom.on_nick_change.connect( + self.secondroom.on_nick_changed.connect( functools.partial(onnickchange, foreign_future), ) - self.firstroom.on_nick_change.connect( + self.firstroom.on_nick_changed.connect( functools.partial(onnickchange, self_future), ) yield from self.firstroom.change_nick("oldhag") - presence, occupant = yield from self_future + occupant, old_nick, new_nick = yield from self_future self.assertEqual(occupant, self.firstroom.me) + self.assertEqual(old_nick, "firstwitch") self.assertEqual(occupant.nick, "oldhag") + self.assertEqual(new_nick, occupant.nick) - presence, occupant = yield from foreign_future + occupant, old_nick, new_nick = yield from foreign_future self.assertEqual(occupant.nick, "oldhag") + self.assertEqual(old_nick, "firstwitch") + self.assertEqual(new_nick, occupant.nick) diff --git a/tests/muc/test_service.py b/tests/muc/test_service.py index d7bb6b44..ae7897f1 100644 --- a/tests/muc/test_service.py +++ b/tests/muc/test_service.py @@ -288,7 +288,7 @@ def setUp(self): for ev in ["on_enter", "on_exit", "on_suspend", "on_resume", "on_message", "on_topic_changed", - "on_join", "on_presence_changed", "on_nick_change", + "on_join", "on_presence_changed", "on_nick_changed", "on_role_change", "on_affiliation_change", "on_leave"]: cb = getattr(self.base, ev) @@ -324,7 +324,7 @@ def test_event_attributes(self): aioxmpp.callbacks.AdHocSignal ) self.assertIsInstance( - self.jmuc.on_nick_change, + self.jmuc.on_nick_changed, aioxmpp.callbacks.AdHocSignal ) self.assertIsInstance( @@ -1098,7 +1098,7 @@ def test__inbound_muc_user_presence_emits_on_presence_changed(self): presence.status ) - def test__inbound_muc_user_presence_emits_on_nick_change(self): + def test__inbound_muc_user_presence_emits_on_nick_changed(self): presence = aioxmpp.stanza.Presence( type_=aioxmpp.structs.PresenceType.AVAILABLE, from_=TEST_MUC_JID.replace(resource="thirdwitch") @@ -1139,7 +1139,11 @@ def test__inbound_muc_user_presence_emits_on_nick_change(self): self.assertSequenceEqual( self.base.mock_calls, [ - unittest.mock.call.on_nick_change(presence, first) + unittest.mock.call.on_nick_changed( + first, + "thirdwitch", + "oldhag", + ) ] ) self.base.mock_calls.clear() @@ -1171,7 +1175,7 @@ def test__inbound_muc_user_presence_emits_on_nick_change(self): ) self.base.mock_calls.clear() - def test__inbound_muc_self_presence_emits_on_nick_change(self): + def test__inbound_muc_self_presence_emits_on_nick_changed(self): presence = aioxmpp.stanza.Presence( type_=aioxmpp.structs.PresenceType.AVAILABLE, from_=TEST_MUC_JID.replace(resource="thirdwitch") @@ -1214,7 +1218,11 @@ def test__inbound_muc_self_presence_emits_on_nick_change(self): self.assertSequenceEqual( self.base.mock_calls, [ - unittest.mock.call.on_nick_change(presence, first) + unittest.mock.call.on_nick_changed( + first, + "thirdwitch", + "oldhag", + ) ] ) self.base.mock_calls.clear() From 6512cd6dd3b616739b0e9264566857a1242d5edf Mon Sep 17 00:00:00 2001 From: Jonas Wielicki Date: Wed, 5 Apr 2017 15:39:13 +0200 Subject: [PATCH 23/40] im-muc: Port on_exit event to AbstractConversation interface --- aioxmpp/muc/service.py | 10 +++++----- tests/muc/test_e2e.py | 28 +++++++++------------------ tests/muc/test_service.py | 40 +++++++++++++++++++-------------------- 3 files changed, 33 insertions(+), 45 deletions(-) diff --git a/aioxmpp/muc/service.py b/aioxmpp/muc/service.py index 75d51b49..80df1b71 100644 --- a/aioxmpp/muc/service.py +++ b/aioxmpp/muc/service.py @@ -506,9 +506,7 @@ def _disconnect(self): if not self._joined: return self.on_exit( - None, - self._this_occupant, - LeaveMode.DISCONNECTED + muc_leave_mode=LeaveMode.DISCONNECTED ) self._joined = False self._active = False @@ -660,7 +658,9 @@ def _handle_self_presence(self, stanza): self._mucjid, reason) existing.update(info) - self.on_exit(stanza, existing, mode, actor=actor, reason=reason) + self.on_exit(muc_leave_mode=mode, + muc_actor=actor, + muc_reason=reason) self._joined = False self._active = False @@ -1072,7 +1072,7 @@ def _handle_message(self, message, peer, sent, source): message, peer, sent, source ) - def _muc_exited(self, muc, stanza, *args, **kwargs): + def _muc_exited(self, muc, *args, **kwargs): try: del self._joined_mucs[muc.jid] except KeyError: diff --git a/tests/muc/test_e2e.py b/tests/muc/test_e2e.py index b41b0249..8fac565a 100644 --- a/tests/muc/test_e2e.py +++ b/tests/muc/test_e2e.py @@ -117,9 +117,9 @@ def test_kick(self): exit_fut = asyncio.Future() leave_fut = asyncio.Future() - def onexit(presence, occupant, mode, **kwargs): + def onexit(muc_leave_mode, **kwargs): nonlocal exit_fut - exit_fut.set_result((presence, occupant, mode)) + exit_fut.set_result((muc_leave_mode,)) return True def onleave(occupant, muc_leave_mode, **kwargs): @@ -135,12 +135,7 @@ def onleave(occupant, muc_leave_mode, **kwargs): "none", reason="Thou art no real witch") - presence, occupant, mode = yield from exit_fut - - self.assertEqual( - presence.type_, - aioxmpp.PresenceType.UNAVAILABLE, - ) + mode, = yield from exit_fut self.assertEqual( mode, @@ -160,9 +155,9 @@ def test_ban(self): exit_fut = asyncio.Future() leave_fut = asyncio.Future() - def onexit(presence, occupant, mode, **kwargs): + def onexit(muc_leave_mode, **kwargs): nonlocal exit_fut - exit_fut.set_result((presence, occupant, mode)) + exit_fut.set_result((muc_leave_mode,)) return True def onleave(occupant, muc_leave_mode, **kwargs): @@ -178,12 +173,7 @@ def onleave(occupant, muc_leave_mode, **kwargs): "outcast", reason="Thou art no real witch") - presence, occupant, mode = yield from exit_fut - - self.assertEqual( - presence.type_, - aioxmpp.PresenceType.UNAVAILABLE, - ) + mode, = yield from exit_fut self.assertEqual( mode, @@ -203,9 +193,9 @@ def test_leave(self): exit_fut = asyncio.Future() leave_fut = asyncio.Future() - def onexit(presence, occupant, mode, **kwargs): + def onexit(muc_leave_mode, **kwargs): nonlocal exit_fut - exit_fut.set_result((presence, occupant, mode)) + exit_fut.set_result((muc_leave_mode,)) return True def onleave(occupant, muc_leave_mode, **kwargs): @@ -221,7 +211,7 @@ def onleave(occupant, muc_leave_mode, **kwargs): self.assertFalse(self.secondroom.active) self.assertFalse(self.secondroom.joined) - presence, occupant, mode = yield from exit_fut + mode, = yield from exit_fut self.assertEqual( mode, aioxmpp.muc.LeaveMode.NORMAL, diff --git a/tests/muc/test_service.py b/tests/muc/test_service.py index ae7897f1..17c780d8 100644 --- a/tests/muc/test_service.py +++ b/tests/muc/test_service.py @@ -486,9 +486,8 @@ def test__disconnect(self): self.base.mock_calls, [ unittest.mock.call.on_exit( - None, - self.jmuc.me, - muc_service.LeaveMode.DISCONNECTED), + muc_leave_mode=muc_service.LeaveMode.DISCONNECTED + ), ] ) @@ -525,9 +524,8 @@ def test__disconnect_during_suspend(self): [ unittest.mock.call.on_suspend(), unittest.mock.call.on_exit( - None, - self.jmuc.me, - muc_service.LeaveMode.DISCONNECTED), + muc_leave_mode=muc_service.LeaveMode.DISCONNECTED + ), ] ) @@ -1581,11 +1579,9 @@ def test__inbound_muc_user_presence_emits_on_enter_and_on_exit(self): self.base.mock_calls, [ unittest.mock.call.on_exit( - presence, - self.jmuc.me, - muc_service.LeaveMode.NORMAL, - actor=None, - reason=None + muc_leave_mode=muc_service.LeaveMode.NORMAL, + muc_actor=None, + muc_reason=None ) ] ) @@ -1645,11 +1641,9 @@ def test_detect_self_presence_from_jid_if_status_is_missing(self): self.base.mock_calls, [ unittest.mock.call.on_exit( - presence, - self.jmuc.me, - muc_service.LeaveMode.KICKED, - actor=None, - reason=None + muc_leave_mode=muc_service.LeaveMode.KICKED, + muc_actor=None, + muc_reason=None ) ] ) @@ -1954,11 +1948,15 @@ def test_leave_and_wait(self): leave.assert_called_with() - self.jmuc.on_exit(object(), object(), object()) + self.jmuc.on_exit(muc_leave_mode=object(), + muc_actor=object(), + muc_reason=object()) self.assertIsNone(run_coroutine(fut)) - self.jmuc.on_exit(object(), object(), object()) + self.jmuc.on_exit(muc_leave_mode=object(), + muc_actor=object(), + muc_reason=object()) def test_members(self): presence = aioxmpp.stanza.Presence( @@ -2980,9 +2978,9 @@ def test_stream_destruction_without_autorejoin(self): [ unittest.mock.call.enter1(unittest.mock.ANY, unittest.mock.ANY), - unittest.mock.call.exit1(None, - room1.me, - muc_service.LeaveMode.DISCONNECTED), + unittest.mock.call.exit1( + muc_leave_mode=muc_service.LeaveMode.DISCONNECTED + ), ] ) base.mock_calls.clear() From 88926d5a67dd3bf1eed1424aa74824106a5583cd Mon Sep 17 00:00:00 2001 From: Jonas Wielicki Date: Wed, 5 Apr 2017 16:37:10 +0200 Subject: [PATCH 24/40] im-muc: Port sending of messages in MUC --- aioxmpp/im/p2p.py | 2 +- aioxmpp/muc/service.py | 32 ++++++++++- tests/im/test_conversation.py | 4 ++ tests/im/test_p2p.py | 24 ++++---- tests/muc/test_e2e.py | 17 ++++-- tests/muc/test_service.py | 102 ++++++++++++++++++++++++++++++++-- 6 files changed, 155 insertions(+), 26 deletions(-) diff --git a/aioxmpp/im/p2p.py b/aioxmpp/im/p2p.py index 9da9d9c3..a58975cb 100644 --- a/aioxmpp/im/p2p.py +++ b/aioxmpp/im/p2p.py @@ -62,7 +62,7 @@ def _handle_message(self, msg, peer, sent, source): self.on_message(msg, member, source) @property - def peer_jid(self): + def jid(self): return self.__peer_jid @property diff --git a/aioxmpp/muc/service.py b/aioxmpp/muc/service.py index 80df1b71..b2b8ce85 100644 --- a/aioxmpp/muc/service.py +++ b/aioxmpp/muc/service.py @@ -33,6 +33,8 @@ import aioxmpp.tracking import aioxmpp.im.conversation import aioxmpp.im.dispatcher +import aioxmpp.im.p2p +import aioxmpp.im.service from . import xso as muc_xso @@ -522,19 +524,25 @@ def _handle_message(self, message, peer, sent, source): self._mucjid, message) + if (self._this_occupant and + self._this_occupant._conversation_jid == message.from_): + occupant = self._this_occupant + else: + occupant = self._occupant_info.get(message.from_, None) + if not message.body and message.subject: self._subject = aioxmpp.structs.LanguageMap(message.subject) self._subject_setter = message.from_.resource self.on_topic_changed( - self._occupant_info.get(message.from_, None), + occupant, self._subject, ) elif message.body: self.on_message( message, - self._occupant_info.get(message.from_, None), + occupant, source, ) @@ -711,6 +719,17 @@ def _inbound_muc_user_presence(self, stanza): muc_reason=reason) del self._occupant_info[existing.conversation_jid] + @asyncio.coroutine + def send_message(self, msg): + msg.type_ = aioxmpp.MessageType.GROUPCHAT + msg.to = self._mucjid + yield from self.service.client.stream.send(msg) + self.on_message( + msg, + self._this_occupant, + aioxmpp.im.dispatcher.MessageSource.STREAM + ) + @asyncio.coroutine def send_message_tracked(self, body): pass @@ -920,9 +939,12 @@ class MUCClient(aioxmpp.service.Service): ORDER_AFTER = [ aioxmpp.im.dispatcher.IMDispatcher, + aioxmpp.im.service.ConversationService, ] - on_muc_joined = aioxmpp.callbacks.Signal() + ORDER_BEFORE = [ + aioxmpp.im.p2p.Service, + ] def __init__(self, client, **kwargs): super().__init__(client, **kwargs) @@ -1174,6 +1196,10 @@ def join(self, mucjid, nick, *, if self.client.established: self._send_join_presence(mucjid, history, nick, password) + self.dependencies[ + aioxmpp.im.service.ConversationService + ]._add_conversation(room) + return room, fut @asyncio.coroutine diff --git a/tests/im/test_conversation.py b/tests/im/test_conversation.py index 13508645..3affc18c 100644 --- a/tests/im/test_conversation.py +++ b/tests/im/test_conversation.py @@ -43,6 +43,10 @@ def members(self): def me(self): pass + @property + def jid(self): + pass + @asyncio.coroutine def send_message_tracked(self, *args, **kwargs): return self.__mock.send_message_tracked(*args, **kwargs) diff --git a/tests/im/test_p2p.py b/tests/im/test_p2p.py index d179e769..747db254 100644 --- a/tests/im/test_p2p.py +++ b/tests/im/test_p2p.py @@ -125,15 +125,15 @@ def test_leave_calls_conversation_left(self): run_coroutine(self.c.leave()) self.svc._conversation_left.assert_called_once_with(self.c) - def test_peer_jid(self): + def test_jid(self): self.assertEqual( - self.c.peer_jid, + self.c.jid, PEER_JID, ) - def test_peer_jid_not_writable(self): + def test_jid_not_writable(self): with self.assertRaises(AttributeError): - self.c.peer_jid = self.c.peer_jid + self.c.jid = self.c.jid def test_message_tracking_not_implemented(self): with self.assertRaises(NotImplementedError): @@ -579,19 +579,23 @@ def test_converse_with_preexisting(self): fwmsgs = [] fwev = asyncio.Event() - def fwevset(*args): + def fwevset(message, member, source): + if member == c1.me: + return + fwmsgs.append(message) fwev.set() swmsgs = [] swev = asyncio.Event() - def swevset(*args): + def swevset(message, member, source): + if member == c2.me: + return + swmsgs.append(message) swev.set() - c1.on_message_received.connect(fwmsgs.append) - c1.on_message_received.connect(fwevset) - c2.on_message_received.connect(swmsgs.append) - c2.on_message_received.connect(swevset) + c1.on_message.connect(fwevset) + c2.on_message.connect(swevset) msg = aioxmpp.Message(aioxmpp.MessageType.CHAT) msg.body[None] = "foo" diff --git a/tests/muc/test_e2e.py b/tests/muc/test_e2e.py index 8fac565a..dde34bfd 100644 --- a/tests/muc/test_e2e.py +++ b/tests/muc/test_e2e.py @@ -299,12 +299,9 @@ def onmessage(message, member, source, **kwargs): self.secondroom.on_message.connect(onmessage) - msg = aioxmpp.Message( - type_=aioxmpp.MessageType.GROUPCHAT, - to=self.mucjid - ) - msg.body[None] = "foo" - yield from self.firstwitch.stream.send(msg) + msg = aioxmpp.Message(type_=aioxmpp.MessageType.CHAT) + msg.body.update({None: "foo"}) + yield from self.firstroom.send_message(msg) message, member, = yield from msg_future self.assertDictEqual( @@ -313,6 +310,14 @@ def onmessage(message, member, source, **kwargs): None: "foo" } ) + self.assertEqual( + message.type_, + aioxmpp.MessageType.GROUPCHAT, + ) + self.assertEqual( + msg.type_, + aioxmpp.MessageType.GROUPCHAT, + ) self.assertCountEqual( [member], diff --git a/tests/muc/test_service.py b/tests/muc/test_service.py index 17c780d8..668a516f 100644 --- a/tests/muc/test_service.py +++ b/tests/muc/test_service.py @@ -30,6 +30,8 @@ import aioxmpp.errors import aioxmpp.forms import aioxmpp.im.dispatcher as im_dispatcher +import aioxmpp.im.service as im_service +import aioxmpp.im.p2p as im_p2p import aioxmpp.muc.service as muc_service import aioxmpp.muc.xso as muc_xso import aioxmpp.service as service @@ -1523,6 +1525,41 @@ def test_inbound_groupchat_message_with_body_emits_on_message_with_member(self): ] ) + def test_inbound_groupchat_message_with_body_emits_on_message_with_member(self): + pres = aioxmpp.stanza.Presence( + from_=TEST_MUC_JID.replace(resource="secondwitch"), + ) + pres.xep0045_muc_user = muc_xso.UserExt(status_codes={110}) + + self.jmuc._inbound_muc_user_presence(pres) + + self.base.on_enter.assert_called_once_with(pres, self.jmuc.me) + self.base.mock_calls.clear() + + msg = aioxmpp.stanza.Message( + from_=TEST_MUC_JID.replace(resource="secondwitch"), + type_=aioxmpp.structs.MessageType.GROUPCHAT, + ) + msg.body[None] = "foo" + + self.jmuc._handle_message( + msg, + msg.from_, + False, + unittest.mock.sentinel.source, + ) + + self.assertSequenceEqual( + self.base.mock_calls, + [ + unittest.mock.call.on_message( + msg, + self.jmuc.me, + unittest.mock.sentinel.source, + ) + ] + ) + def test__inbound_muc_user_presence_emits_on_enter_and_on_exit(self): presence = aioxmpp.stanza.Presence( type_=aioxmpp.structs.PresenceType.AVAILABLE, @@ -2110,6 +2147,46 @@ def test_request_voice(self): ["participant"] ) + def test_send_message(self): + msg = aioxmpp.Message(aioxmpp.MessageType.NORMAL) + msg.body.update({None: "some text"}) + + run_coroutine( + self.jmuc.send_message(msg) + ) + + self.base.service.client.stream.send.assert_called_once_with( + unittest.mock.ANY, + ) + + _, (msg, ), _ = self.base.service.client.stream.send.mock_calls[0] + + self.assertIsInstance( + msg, + aioxmpp.Message, + ) + + self.assertEqual( + msg.type_, + aioxmpp.MessageType.GROUPCHAT, + ) + + self.assertEqual( + msg.to, + self.jmuc.jid, + ) + + self.assertDictEqual( + msg.body, + {None: "some text"}, + ) + + self.base.on_message.assert_called_once_with( + msg, + self.jmuc.me, + im_dispatcher.MessageSource.STREAM, + ) + class TestService(unittest.TestCase): def test_is_service(self): @@ -2121,22 +2198,32 @@ def test_is_service(self): def setUp(self): self.cc = make_connected_client() self.im_dispatcher = im_dispatcher.IMDispatcher(self.cc) + self.im_service = unittest.mock.Mock( + spec=im_service.ConversationService + ) self.s = muc_service.MUCClient(self.cc, dependencies={ im_dispatcher.IMDispatcher: self.im_dispatcher, + im_service.ConversationService: self.im_service, }) - def test_event_attributes(self): - self.assertIsInstance( - self.s.on_muc_joined, - aioxmpp.callbacks.AdHocSignal - ) - def test_depends_on_IMDispatcher(self): self.assertIn( im_dispatcher.IMDispatcher, muc_service.MUCClient.ORDER_AFTER, ) + def test_depends_on_ConversationService(self): + self.assertIn( + im_service.ConversationService, + muc_service.MUCClient.ORDER_AFTER, + ) + + def test_orders_before_P2P_Service(self): + self.assertIn( + im_p2p.Service, + muc_service.MUCClient.ORDER_BEFORE, + ) + def test_handle_presence_is_decorated(self): self.assertTrue( aioxmpp.service.is_depfilter_handler( @@ -2274,6 +2361,9 @@ def test_join_without_password_or_history(self): self.s.get_muc(TEST_MUC_JID) room, future = self.s.join(TEST_MUC_JID, "thirdwitch") + + self.im_service._add_conversation.assert_called_once_with(room) + self.assertIs( self.s.get_muc(TEST_MUC_JID), room From ec825aa28e26ec62dde71d2240943cf8ba0b3a26 Mon Sep 17 00:00:00 2001 From: Jonas Wielicki Date: Wed, 5 Apr 2017 18:41:17 +0200 Subject: [PATCH 25/40] im-muc: Implement Message Tracking for MUCs --- aioxmpp/im/conversation.py | 6 +- aioxmpp/muc/service.py | 80 ++++++++- tests/muc/test_e2e.py | 11 +- tests/muc/test_service.py | 357 ++++++++++++++++++++++++++++++++++++- 4 files changed, 440 insertions(+), 14 deletions(-) diff --git a/aioxmpp/im/conversation.py b/aioxmpp/im/conversation.py index 441f789f..81f64cd1 100644 --- a/aioxmpp/im/conversation.py +++ b/aioxmpp/im/conversation.py @@ -568,11 +568,7 @@ def send_message_tracked(self, body, *, timeout=None): .. warning:: - Active tracking objects consume memory for storing the state. It is - advisable to either set a `timeout` or - :meth:`.tracking.MessageTracker.cancel` the tracking from the - application at some point to prevent degration of performance and - running out of memory. + Read :ref:`api-tracking-memory`. .. seealso:: diff --git a/aioxmpp/muc/service.py b/aioxmpp/muc/service.py index b2b8ce85..fb6e2708 100644 --- a/aioxmpp/muc/service.py +++ b/aioxmpp/muc/service.py @@ -420,7 +420,9 @@ def __init__(self, service, mucjid): self._joined = False self._active = False self._this_occupant = None - self._tracking = {} + self._tracking_by_id = {} + self._tracking_metadata = {} + self._tracking_by_sender_body = {} self.autorejoin = False self.password = None @@ -519,11 +521,44 @@ def _resume(self): self._active = False self.on_resume() + def _match_tracker(self, message): + try: + tracker = self._tracking_by_id[message.id_] + except KeyError: + try: + tracker = self._tracking_by_sender_body[ + message.from_, message.body.get(None) + ] + except KeyError: + tracker = None + if tracker is None: + return False + + id_key, sender_body_key = self._tracking_metadata.pop(tracker) + del self._tracking_by_id[id_key] + del self._tracking_by_sender_body[sender_body_key] + + try: + tracker._set_state( + aioxmpp.tracking.MessageState.DELIVERED_TO_RECIPIENT, + message, + ) + except ValueError: + # this can happen if another implementation was faster with + # changing the state than we were. + pass + + return True + def _handle_message(self, message, peer, sent, source): self._service.logger.debug("%s: inbound message %r", self._mucjid, message) + if not sent: + if self._match_tracker(message): + return + if (self._this_occupant and self._this_occupant._conversation_jid == message.from_): occupant = self._this_occupant @@ -723,6 +758,7 @@ def _inbound_muc_user_presence(self, stanza): def send_message(self, msg): msg.type_ = aioxmpp.MessageType.GROUPCHAT msg.to = self._mucjid + msg.xep0045_muc_user = muc_xso.UserExt() yield from self.service.client.stream.send(msg) self.on_message( msg, @@ -730,9 +766,39 @@ def send_message(self, msg): aioxmpp.im.dispatcher.MessageSource.STREAM ) + def _tracker_closed(self, tracker): + try: + id_key, sender_body_key = self._tracking_metadata[tracker] + except KeyError: + return + self._tracking_by_id.pop(id_key, None) + self._tracking_by_sender_body.pop(sender_body_key, None) + @asyncio.coroutine - def send_message_tracked(self, body): - pass + def send_message_tracked(self, msg): + msg.type_ = aioxmpp.MessageType.GROUPCHAT + msg.to = self._mucjid + msg.xep0045_muc_user = muc_xso.UserExt() + msg.autoset_id() + tracking_svc = self.service.dependencies[ + aioxmpp.tracking.BasicTrackingService + ] + tracker = aioxmpp.tracking.MessageTracker() + id_key = msg.id_ + sender_body_key = (self._this_occupant.conversation_jid, + msg.body.get(None)) + self._tracking_by_id[id_key] = tracker + self._tracking_metadata[tracker] = ( + id_key, + sender_body_key, + ) + self._tracking_by_sender_body[sender_body_key] = tracker + tracker.on_closed.connect(functools.partial( + self._tracker_closed, + tracker, + )) + yield from tracking_svc.send_tracked(msg, tracker) + return tracker @asyncio.coroutine def change_nick(self, new_nick): @@ -940,6 +1006,7 @@ class MUCClient(aioxmpp.service.Service): ORDER_AFTER = [ aioxmpp.im.dispatcher.IMDispatcher, aioxmpp.im.service.ConversationService, + aioxmpp.tracking.BasicTrackingService, ] ORDER_BEFORE = [ @@ -1084,6 +1151,13 @@ def _handle_presence(self, stanza, peer, sent): aioxmpp.im.dispatcher.IMDispatcher, "message_filter") def _handle_message(self, message, peer, sent, source): + if (source == aioxmpp.im.dispatcher.MessageSource.CARBONS + and message.xep0045_muc_user): + return None + + if message.type_ != aioxmpp.MessageType.GROUPCHAT: + return message + mucjid = peer.bare() try: muc = self._joined_mucs[mucjid] diff --git a/tests/muc/test_e2e.py b/tests/muc/test_e2e.py index dde34bfd..3db67a16 100644 --- a/tests/muc/test_e2e.py +++ b/tests/muc/test_e2e.py @@ -256,27 +256,28 @@ def onsubject(member, subject, **kwargs): "firstwitch", ) - @skip_with_quirk(Quirk.MUC_REWRITES_MESSAGE_ID) @blocking_timed @asyncio.coroutine def test_send_tracked_message(self): msg_future = asyncio.Future() sent_future = asyncio.Future() - def onmessage(message, **kwargs): + def onmessage(message, member, source, **kwargs): nonlocal msg_future msg_future.set_result((message,)) return True - def onstatechange(state): + def onstatechange(state, response=None): if state == aioxmpp.tracking.MessageState.DELIVERED_TO_RECIPIENT: sent_future.set_result(None) return True self.secondroom.on_message.connect(onmessage) - tracker = self.firstroom.send_tracked_message({None: "foo"}) - tracker.on_state_change.connect(onstatechange) + msg = aioxmpp.Message(aioxmpp.MessageType.NORMAL) + msg.body[None] = "foo" + tracker = yield from self.firstroom.send_message_tracked(msg) + tracker.on_state_changed.connect(onstatechange) yield from sent_future message, = yield from msg_future diff --git a/tests/muc/test_service.py b/tests/muc/test_service.py index 668a516f..0de8c5ac 100644 --- a/tests/muc/test_service.py +++ b/tests/muc/test_service.py @@ -37,6 +37,7 @@ import aioxmpp.service as service import aioxmpp.stanza import aioxmpp.structs +import aioxmpp.tracking import aioxmpp.utils as utils from aioxmpp.testutils import ( @@ -285,6 +286,11 @@ def setUp(self): self.base.service.logger = unittest.mock.Mock(name="logger") self.base.service.client.stream.send = \ CoroutineMock() + self.base.service.dependencies = {} + self.base.service.dependencies[ + aioxmpp.tracking.BasicTrackingService + ] = self.base.tracking_service + self.base.tracking_service.send_tracked = CoroutineMock() self.jmuc = muc_service.Room(self.base.service, self.mucjid) @@ -2181,12 +2187,269 @@ def test_send_message(self): {None: "some text"}, ) + self.assertIsInstance( + msg.xep0045_muc_user, + muc_xso.UserExt, + ) + self.base.on_message.assert_called_once_with( msg, self.jmuc.me, im_dispatcher.MessageSource.STREAM, ) + def test_send_message_tracked_uses_basic_tracking_service(self): + presence = aioxmpp.stanza.Presence( + type_=aioxmpp.structs.PresenceType.AVAILABLE, + from_=TEST_MUC_JID.replace(resource="thirdwitch") + ) + presence.xep0045_muc_user = muc_xso.UserExt( + items=[ + muc_xso.UserItem(affiliation="member", + role="participant"), + ], + status_codes={110}, + ) + + self.jmuc._inbound_muc_user_presence(presence) + + msg = aioxmpp.Message(aioxmpp.MessageType.NORMAL) + msg.body.update({None: "some text"}) + + with contextlib.ExitStack() as stack: + MessageTracker = stack.enter_context( + unittest.mock.patch("aioxmpp.tracking.MessageTracker") + ) + + result = run_coroutine( + self.jmuc.send_message_tracked(msg) + ) + + self.assertIsNotNone(msg.id_) + + MessageTracker.assert_called_once_with() + + self.base.tracking_service.send_tracked.assert_called_once_with( + msg, + MessageTracker() + ) + + self.assertEqual( + result, + MessageTracker(), + ) + + self.assertIsInstance( + msg, + aioxmpp.Message, + ) + + self.assertEqual( + msg.type_, + aioxmpp.MessageType.GROUPCHAT, + ) + + self.assertEqual( + msg.to, + self.jmuc.jid, + ) + + self.assertDictEqual( + msg.body, + {None: "some text"}, + ) + + self.assertIsInstance( + msg.xep0045_muc_user, + muc_xso.UserExt, + ) + + def test_tracker_changes_state_on_reflection(self): + presence = aioxmpp.stanza.Presence( + type_=aioxmpp.structs.PresenceType.AVAILABLE, + from_=TEST_MUC_JID.replace(resource="thirdwitch") + ) + presence.xep0045_muc_user = muc_xso.UserExt( + items=[ + muc_xso.UserItem(affiliation="member", + role="participant"), + ], + status_codes={110}, + ) + + self.jmuc._inbound_muc_user_presence(presence) + + msg = aioxmpp.Message(aioxmpp.MessageType.NORMAL) + msg.body.update({None: "some text"}) + + tracker = run_coroutine( + self.jmuc.send_message_tracked(msg) + ) + + self.assertEqual( + tracker.state, + aioxmpp.tracking.MessageState.IN_TRANSIT, + ) + + reflected = aioxmpp.Message( + type_=aioxmpp.MessageType.GROUPCHAT, + from_=self.jmuc.me.conversation_jid, + id_=msg.id_, + ) + reflected.body[None] = "other text" + + self.jmuc._handle_message( + reflected, + reflected.from_, + False, + im_dispatcher.MessageSource.STREAM, + ) + + self.assertEqual( + tracker.state, + aioxmpp.tracking.MessageState.DELIVERED_TO_RECIPIENT, + ) + + self.assertEqual(tracker.response, reflected) + + self.base.on_message.assert_not_called() + + def test_tracker_matches_on_body_and_from_too(self): + presence = aioxmpp.stanza.Presence( + type_=aioxmpp.structs.PresenceType.AVAILABLE, + from_=TEST_MUC_JID.replace(resource="thirdwitch") + ) + presence.xep0045_muc_user = muc_xso.UserExt( + items=[ + muc_xso.UserItem(affiliation="member", + role="participant"), + ], + status_codes={110}, + ) + + self.jmuc._inbound_muc_user_presence(presence) + + msg = aioxmpp.Message(aioxmpp.MessageType.NORMAL) + msg.body.update({None: "some text"}) + + tracker = run_coroutine( + self.jmuc.send_message_tracked(msg) + ) + + self.assertEqual( + tracker.state, + aioxmpp.tracking.MessageState.IN_TRANSIT, + ) + + reflected = aioxmpp.Message( + type_=aioxmpp.MessageType.GROUPCHAT, + from_=self.jmuc.me.conversation_jid, + id_="#notmyid", + ) + reflected.body[None] = "some text" + + self.jmuc._handle_message( + reflected, + reflected.from_, + False, + im_dispatcher.MessageSource.STREAM, + ) + + self.assertEqual( + tracker.state, + aioxmpp.tracking.MessageState.DELIVERED_TO_RECIPIENT, + ) + + self.assertEqual(tracker.response, reflected) + + self.base.on_message.assert_not_called() + + def test_tracking_does_not_fail_on_race(self): + presence = aioxmpp.stanza.Presence( + type_=aioxmpp.structs.PresenceType.AVAILABLE, + from_=TEST_MUC_JID.replace(resource="thirdwitch") + ) + presence.xep0045_muc_user = muc_xso.UserExt( + items=[ + muc_xso.UserItem(affiliation="member", + role="participant"), + ], + status_codes={110}, + ) + + self.jmuc._inbound_muc_user_presence(presence) + + msg = aioxmpp.Message(aioxmpp.MessageType.NORMAL) + msg.body.update({None: "some text"}) + + tracker = run_coroutine( + self.jmuc.send_message_tracked(msg) + ) + tracker._set_state(aioxmpp.tracking.MessageState.SEEN_BY_RECIPIENT) + + reflected = aioxmpp.Message( + type_=aioxmpp.MessageType.GROUPCHAT, + from_=self.jmuc.me.conversation_jid, + id_=msg.id_, + ) + reflected.body[None] = "some text" + + self.jmuc._handle_message( + reflected, + reflected.from_, + False, + im_dispatcher.MessageSource.STREAM, + ) + + self.base.on_message.assert_not_called() + + def test_tracking_state_cleanup_on_close(self): + presence = aioxmpp.stanza.Presence( + type_=aioxmpp.structs.PresenceType.AVAILABLE, + from_=TEST_MUC_JID.replace(resource="thirdwitch") + ) + presence.xep0045_muc_user = muc_xso.UserExt( + items=[ + muc_xso.UserItem(affiliation="member", + role="participant"), + ], + status_codes={110}, + ) + + self.jmuc._inbound_muc_user_presence(presence) + + msg = aioxmpp.Message(aioxmpp.MessageType.NORMAL) + msg.body.update({None: "some text"}) + + tracker = run_coroutine( + self.jmuc.send_message_tracked(msg) + ) + + self.assertEqual( + tracker.state, + aioxmpp.tracking.MessageState.IN_TRANSIT, + ) + + tracker.close() + + reflected = aioxmpp.Message( + type_=aioxmpp.MessageType.GROUPCHAT, + from_=self.jmuc.me.conversation_jid, + id_=msg.id_, + ) + + self.jmuc._handle_message( + reflected, + reflected.from_, + False, + im_dispatcher.MessageSource.STREAM, + ) + + self.assertEqual( + tracker.state, + aioxmpp.tracking.MessageState.IN_TRANSIT, + ) + class TestService(unittest.TestCase): def test_is_service(self): @@ -2201,9 +2464,13 @@ def setUp(self): self.im_service = unittest.mock.Mock( spec=im_service.ConversationService ) + self.tracking_service = unittest.mock.Mock( + spec=aioxmpp.tracking.BasicTrackingService + ) self.s = muc_service.MUCClient(self.cc, dependencies={ im_dispatcher.IMDispatcher: self.im_dispatcher, im_service.ConversationService: self.im_service, + aioxmpp.tracking.BasicTrackingService: self.tracking_service, }) def test_depends_on_IMDispatcher(self): @@ -2218,6 +2485,12 @@ def test_depends_on_ConversationService(self): muc_service.MUCClient.ORDER_AFTER, ) + def test_depends_on_BasicTrackingService(self): + self.assertIn( + aioxmpp.tracking.BasicTrackingService, + muc_service.MUCClient.ORDER_AFTER, + ) + def test_orders_before_P2P_Service(self): self.assertIn( im_p2p.Service, @@ -2247,6 +2520,7 @@ def test_handle_message_ignores_unknown_groupchat_stanza(self): type_=aioxmpp.MessageType.GROUPCHAT, from_=TEST_MUC_JID.replace(resource="firstwitch"), ) + msg.xep0045_muc_user = muc_xso.UserExt() self.assertIs( msg, self.s._handle_message( @@ -2257,6 +2531,36 @@ def test_handle_message_ignores_unknown_groupchat_stanza(self): ) ) + def test_handle_message_ignores_nonmuc_ccd_message(self): + msg = aioxmpp.Message( + type_=aioxmpp.MessageType.CHAT, + from_=TEST_MUC_JID.replace(resource="firstwitch"), + ) + self.assertIs( + msg, + self.s._handle_message( + msg, + msg.from_, + False, + im_dispatcher.MessageSource.CARBONS, + ) + ) + + def test_handle_message_drops_received_carbon_of_pm(self): + msg = aioxmpp.Message( + type_=aioxmpp.MessageType.CHAT, + from_=TEST_MUC_JID.replace(resource="firstwitch"), + ) + msg.xep0045_muc_user = muc_xso.UserExt() + self.assertIsNone( + self.s._handle_message( + msg, + msg.from_, + False, + im_dispatcher.MessageSource.CARBONS, + ) + ) + def test__stream_established_is_decorated(self): self.assertTrue( aioxmpp.service.is_depsignal_handler( @@ -2689,7 +2993,7 @@ def mkpresence(nick): ] ) - def test_forward_messages_to_joined_mucs(self): + def test_forward_groupchat_messages_to_joined_mucs(self): room, future = self.s.join(TEST_MUC_JID, "thirdwitch") def mkpresence(nick, is_self=False): @@ -2744,6 +3048,57 @@ def mkpresence(nick, is_self=False): unittest.mock.sentinel.source, ) + def test_ignores_chat_messages_from_joined_mucs(self): + room, future = self.s.join(TEST_MUC_JID, "thirdwitch") + + def mkpresence(nick, is_self=False): + presence = aioxmpp.stanza.Presence( + from_=TEST_MUC_JID.replace(resource=nick) + ) + presence.xep0045_muc_user = muc_xso.UserExt( + status_codes={110} if is_self else set() + ) + return presence + + occupant_presences = [ + mkpresence(nick, is_self=(nick == "thirdwitch")) + for nick in [ + "firstwitch", + "secondwitch", + "thirdwitch", + ] + ] + + msg = aioxmpp.stanza.Message( + from_=TEST_MUC_JID.replace(resource="firstwitch"), + type_=aioxmpp.structs.MessageType.CHAT, + ) + + with contextlib.ExitStack() as stack: + _handle_message = stack.enter_context(unittest.mock.patch.object( + room, + "_handle_message", + )) + + for presence in occupant_presences: + self.s._handle_presence( + presence, + presence.from_, + False, + ) + + self.assertIs( + msg, + self.s._handle_message( + msg, + msg.from_, + unittest.mock.sentinel.sent, + unittest.mock.sentinel.source, + ) + ) + + _handle_message.assert_not_called() + def test_muc_is_untracked_when_user_leaves(self): room, future = self.s.join(TEST_MUC_JID, "thirdwitch") From c93c9b163649b1bc02defd6662ed55c7bc3f6f56 Mon Sep 17 00:00:00 2001 From: Jonas Wielicki Date: Wed, 5 Apr 2017 20:48:29 +0200 Subject: [PATCH 26/40] im-muc: Make the MUC Room methods adhere to AbstractConversation --- aioxmpp/im/conversation.py | 38 +++++++-- aioxmpp/muc/service.py | 90 ++++++++++++--------- tests/muc/test_e2e.py | 128 ++++++++++++++++++++++++++++-- tests/muc/test_service.py | 157 +++++++++++++++++++++++++++---------- 4 files changed, 319 insertions(+), 94 deletions(-) diff --git a/aioxmpp/im/conversation.py b/aioxmpp/im/conversation.py index 81f64cd1..e6561329 100644 --- a/aioxmpp/im/conversation.py +++ b/aioxmpp/im/conversation.py @@ -520,15 +520,17 @@ def features(self): """ return set() - @asyncio.coroutine def send_message(self, body): """ Send a message to the conversation. :param body: The message body. + :return: The stanza token obtained from sending. + :rtype: :class:`~aioxmpp.stream.StanzaToken` The default implementation simply calls :meth:`send_message_tracked` - and immediately cancels the tracking object. + and immediately cancels the tracking object, returning only the stanza + token. Subclasses may override this method with a more specialised implementation. Subclasses which do not provide tracked message sending @@ -545,7 +547,6 @@ def send_message(self, body): tracker.cancel() @abc.abstractmethod - @asyncio.coroutine def send_message_tracked(self, body, *, timeout=None): """ Send a message to the conversation with tracking. @@ -555,6 +556,9 @@ def send_message_tracked(self, body, *, timeout=None): :type timeout: :class:`numbers.RealNumber`, :class:`datetime.timedelta` or :data:`None` :raise NotImplementedError: if tracking is not implemented + :return: The stanza token obtained from sending and the + :class:`aioxmpp.tracking.MessageTracker` tracking the delivery. + :rtype: :class:`~aioxmpp.stream.StanzaToken` Tracking may not be supported by all implementations, and the degree of support varies with implementation. Please check the documentation @@ -579,11 +583,13 @@ def send_message_tracked(self, body, *, timeout=None): """ @asyncio.coroutine - def kick(self, member): + def kick(self, member, reason=None): """ Kick a member from a conversation. :param member: The member to kick. + :param reason: A reason to show to the kicked member. + :type reason: :class:`str` :raises aioxmpp.errors.XMPPError: if the server returned an error for the kick command. @@ -595,10 +601,14 @@ def kick(self, member): raise self._not_implemented_error("kicking members") @asyncio.coroutine - def ban(self, member, *, request_kick=True): + def ban(self, member, reason=None, *, request_kick=True): """ Ban a member from re-joining a conversation. + :param member: The member to ban. + :param reason: A reason to show to the banned member. + :type reason: :class:`str` + If `request_kick` is true, the implementation attempts to kick the member from the conversation, too, if that does not happen automatically. There is no guarantee that the member is not removed @@ -687,17 +697,31 @@ def set_nick(self, new_nickname): details on the semantics of features. """ + raise self._not_implemented_error("changing the nickname") @asyncio.coroutine def set_topic(self, new_topic): """ Change the (possibly publicly) visible topic of the conversation. + :param new_topic: The new topic for the conversation. + :type new_topic: :class:`str` + + Sends the request to change the topic and waits for the request to + be sent. + + There is no guarantee that the topic change will actually be + applied; listen to the :meth:`on_topic_chagned` event. + + Implementations may provide a different method which provides more + feedback. + .. seealso:: - The corresponding feature is + The corresponding feature for this method is :attr:`.ConversationFeature.SET_TOPIC`. See :attr:`features` for - details. + details on the semantics of features. + """ raise self._not_implemented_error("changing the topic") diff --git a/aioxmpp/muc/service.py b/aioxmpp/muc/service.py index fb6e2708..49a36864 100644 --- a/aioxmpp/muc/service.py +++ b/aioxmpp/muc/service.py @@ -218,12 +218,10 @@ class Room(aioxmpp.im.conversation.AbstractConversation): .. autoattribute:: members - .. automethod:: change_nick + .. automethod:: set_nick .. automethod:: leave - .. automethod:: leave_and_wait - .. automethod:: request_voice .. automethod:: set_role @@ -502,6 +500,18 @@ def members(self): items += list(self._occupant_info.values()) return items + @property + def features(self): + return { + aioxmpp.im.conversation.ConversationFeature.BAN, + aioxmpp.im.conversation.ConversationFeature.BAN_WITH_KICK, + aioxmpp.im.conversation.ConversationFeature.KICK, + aioxmpp.im.conversation.ConversationFeature.SEND_MESSAGE, + aioxmpp.im.conversation.ConversationFeature.SEND_MESSAGE_TRACKED, + aioxmpp.im.conversation.ConversationFeature.SET_TOPIC, + aioxmpp.im.conversation.ConversationFeature.SET_NICK, + } + def _suspend(self): self.on_suspend() self._active = False @@ -801,7 +811,7 @@ def send_message_tracked(self, msg): return tracker @asyncio.coroutine - def change_nick(self, new_nick): + def set_nick(self, new_nick): """ Change the nick name of the occupant. @@ -824,7 +834,15 @@ def change_nick(self, new_nick): ) @asyncio.coroutine - def set_role(self, nick, role, *, reason=None): + def kick(self, member, reason=None): + yield from self.muc_set_role( + member.nick, + "none", + reason=reason + ) + + @asyncio.coroutine + def muc_set_role(self, nick, role, *, reason=None): """ Change the role of an occupant, identified by their `nick`, to the given new `role`. Optionally, a `reason` for the role change can be @@ -863,7 +881,20 @@ def set_role(self, nick, role, *, reason=None): ) @asyncio.coroutine - def set_affiliation(self, jid, affiliation, *, reason=None): + def ban(self, member, reason=None, *, request_kick=True): + if member.direct_jid is None: + raise ValueError( + "cannot ban members whose direct JID is not " + "known") + + yield from self.muc_set_affiliation( + member.direct_jid, + "outcast", + reason=reason + ) + + @asyncio.coroutine + def muc_set_affiliation(self, jid, affiliation, *, reason=None): """ Convenience wrapper around :meth:`.MUCClient.set_affiliation`. See there for details, and consider its `mucjid` argument to be set to @@ -874,7 +905,8 @@ def set_affiliation(self, jid, affiliation, *, reason=None): jid, affiliation, reason=reason)) - def set_subject(self, subject): + @asyncio.coroutine + def set_topic(self, new_topic): """ Request to set the subject to `subject`. `subject` must be a mapping which maps :class:`~.structs.LanguageTag` tags to strings; :data:`None` @@ -887,52 +919,34 @@ def set_subject(self, subject): type_=aioxmpp.structs.MessageType.GROUPCHAT, to=self._mucjid ) - msg.subject.update(subject) - - return self.service.client.stream.enqueue(msg) - - def leave(self): - """ - Request to leave the MUC. + msg.subject.update(new_topic) - This sends unavailable presence to the bare :attr:`mucjid`. When the - leave is completed, :meth:`on_exit` fires. - - .. seealso:: - - Method :meth:`leave_and_wait` - A coroutine which calls :meth:`leave` and returns when - :meth:`on_exit` is fired. - - """ - presence = aioxmpp.stanza.Presence( - type_=aioxmpp.structs.PresenceType.UNAVAILABLE, - to=self._mucjid - ) - self.service.client.stream.enqueue(presence) + yield from self.service.client.stream.send(msg) @asyncio.coroutine - def leave_and_wait(self): + def leave(self): """ Request to leave the MUC and wait for it. This effectively calls :meth:`leave` and waits for the next :meth:`on_exit` event. """ - fut = asyncio.Future() + fut = self.on_exit.future() - self.leave() - - def on_exit(*args, **kwargs): + def cb(**kwargs): fut.set_result(None) - return True + return True # disconnect + + self.on_exit.connect(cb) - self.on_exit.connect( - on_exit + presence = aioxmpp.stanza.Presence( + type_=aioxmpp.structs.PresenceType.UNAVAILABLE, + to=self._mucjid ) + yield from self.service.client.stream.send(presence) yield from fut @asyncio.coroutine - def request_voice(self): + def muc_request_voice(self): """ Request voice (participant role) in the room and wait for the request to be sent. diff --git a/tests/muc/test_e2e.py b/tests/muc/test_e2e.py index 3db67a16..659cf891 100644 --- a/tests/muc/test_e2e.py +++ b/tests/muc/test_e2e.py @@ -76,6 +76,13 @@ def setUp(self, muc_provider): # owner of the muc yield from fut + secondwitch_fut = asyncio.Future() + def cb(member, **kwargs): + secondwitch_fut.set_result(member) + return True + + self.firstroom.on_join.connect(cb) + self.secondroom, fut = self.secondwitch.summon( aioxmpp.MUCClient ).join( @@ -85,6 +92,11 @@ def setUp(self, muc_provider): yield from fut + # we also want to wait until firstwitch sees secondwitch + + member = yield from secondwitch_fut + self.assertIn(member, self.firstroom.members) + @blocking_timed @asyncio.coroutine def test_join(self): @@ -111,12 +123,64 @@ def onjoin(occupant, **kwargs): self.mucjid.replace(resource="thirdwitch"), ) + self.assertIn(occupant, self.firstroom.members) + @blocking_timed @asyncio.coroutine def test_kick(self): exit_fut = asyncio.Future() leave_fut = asyncio.Future() + def onexit(muc_leave_mode, muc_reason=None, **kwargs): + nonlocal exit_fut + exit_fut.set_result((muc_leave_mode, muc_reason)) + return True + + def onleave(occupant, muc_leave_mode, muc_reason=None, **kwargs): + nonlocal leave_fut + leave_fut.set_result((occupant, muc_leave_mode, muc_reason)) + return True + + self.secondroom.on_exit.connect(onexit) + self.firstroom.on_leave.connect(onleave) + + for witch in self.firstroom.members: + if witch.nick == "secondwitch": + yield from self.firstroom.kick(witch, "Thou art no real witch") + break + else: + self.assertFalse(True, "secondwitch not found in members") + + mode, reason = yield from exit_fut + + self.assertEqual( + mode, + aioxmpp.muc.LeaveMode.KICKED, + ) + + self.assertEqual( + reason, + "Thou art no real witch", + ) + + occupant, mode, reason = yield from leave_fut + + self.assertEqual( + mode, + aioxmpp.muc.LeaveMode.KICKED, + ) + + self.assertEqual( + reason, + "Thou art no real witch", + ) + + @blocking_timed + @asyncio.coroutine + def test_kick_using_set_role(self): + exit_fut = asyncio.Future() + leave_fut = asyncio.Future() + def onexit(muc_leave_mode, **kwargs): nonlocal exit_fut exit_fut.set_result((muc_leave_mode,)) @@ -130,7 +194,7 @@ def onleave(occupant, muc_leave_mode, **kwargs): self.secondroom.on_exit.connect(onexit) self.firstroom.on_leave.connect(onleave) - yield from self.firstroom.set_role( + yield from self.firstroom.muc_set_role( "secondwitch", "none", reason="Thou art no real witch") @@ -155,6 +219,56 @@ def test_ban(self): exit_fut = asyncio.Future() leave_fut = asyncio.Future() + def onexit(muc_leave_mode, muc_reason=None, **kwargs): + nonlocal exit_fut + exit_fut.set_result((muc_leave_mode, muc_reason)) + return True + + def onleave(occupant, muc_leave_mode, muc_reason=None, **kwargs): + nonlocal leave_fut + leave_fut.set_result((occupant, muc_leave_mode, muc_reason)) + return True + + self.secondroom.on_exit.connect(onexit) + self.firstroom.on_leave.connect(onleave) + + for witch in self.firstroom.members: + if witch.nick == "secondwitch": + yield from self.firstroom.ban(witch, "Treason!") + break + else: + self.assertFalse(True, "secondwitch not found in members") + + mode, reason = yield from exit_fut + + self.assertEqual( + mode, + aioxmpp.muc.LeaveMode.BANNED, + ) + + self.assertEqual( + reason, + "Treason!", + ) + + occupant, mode, reason = yield from leave_fut + + self.assertEqual( + mode, + aioxmpp.muc.LeaveMode.BANNED, + ) + + self.assertEqual( + reason, + "Treason!", + ) + + @blocking_timed + @asyncio.coroutine + def test_ban_using_set_affiliation(self): + exit_fut = asyncio.Future() + leave_fut = asyncio.Future() + def onexit(muc_leave_mode, **kwargs): nonlocal exit_fut exit_fut.set_result((muc_leave_mode,)) @@ -168,7 +282,7 @@ def onleave(occupant, muc_leave_mode, **kwargs): self.secondroom.on_exit.connect(onexit) self.firstroom.on_leave.connect(onleave) - yield from self.firstroom.set_affiliation( + yield from self.firstroom.muc_set_affiliation( self.secondwitch.local_jid.bare(), "outcast", reason="Thou art no real witch") @@ -206,7 +320,7 @@ def onleave(occupant, muc_leave_mode, **kwargs): self.firstroom.on_leave.connect(onleave) self.secondroom.on_exit.connect(onexit) - yield from self.secondroom.leave_and_wait() + yield from self.secondroom.leave() self.assertFalse(self.secondroom.active) self.assertFalse(self.secondroom.joined) @@ -225,7 +339,7 @@ def onleave(occupant, muc_leave_mode, **kwargs): @blocking_timed @asyncio.coroutine - def test_set_subject(self): + def test_set_topic(self): subject_fut = asyncio.Future() def onsubject(member, subject, **kwargs): @@ -235,7 +349,7 @@ def onsubject(member, subject, **kwargs): self.secondroom.on_topic_changed.connect(onsubject) - self.firstroom.set_subject({None: "Wytches Brew!"}) + yield from self.firstroom.set_topic({None: "Wytches Brew!"}) member, subject = yield from subject_fut @@ -329,7 +443,7 @@ def onmessage(message, member, source, **kwargs): @blocking_timed @asyncio.coroutine - def test_change_nick(self): + def test_set_nick(self): self_future = asyncio.Future() foreign_future = asyncio.Future() @@ -345,7 +459,7 @@ def onnickchange(fut, occupant, old_nick, new_nick, **kwargs): functools.partial(onnickchange, self_future), ) - yield from self.firstroom.change_nick("oldhag") + yield from self.firstroom.set_nick("oldhag") occupant, old_nick, new_nick = yield from self_future self.assertEqual(occupant, self.firstroom.me) diff --git a/tests/muc/test_service.py b/tests/muc/test_service.py index 0de8c5ac..189449b0 100644 --- a/tests/muc/test_service.py +++ b/tests/muc/test_service.py @@ -29,6 +29,7 @@ import aioxmpp.callbacks import aioxmpp.errors import aioxmpp.forms +import aioxmpp.im.conversation as im_conversation import aioxmpp.im.dispatcher as im_dispatcher import aioxmpp.im.service as im_service import aioxmpp.im.p2p as im_p2p @@ -1773,7 +1774,7 @@ def test__inbound_muc_user_presence_ignores_self_leave_if_inactive(self): self.assertFalse(self.jmuc.active) self.assertIsNone(self.jmuc.me) - def test_set_role(self): + def test_muc_set_role(self): new_role = "participant" with unittest.mock.patch.object( @@ -1782,7 +1783,7 @@ def test_set_role(self): new=CoroutineMock()) as send_iq: send_iq.return_value = None - run_coroutine(self.jmuc.set_role( + run_coroutine(self.jmuc.muc_set_role( "thirdwitch", new_role, reason="foobar", @@ -1830,7 +1831,7 @@ def test_set_role(self): new_role ) - def test_set_role_rejects_None_nick(self): + def test_muc_set_role_rejects_None_nick(self): with unittest.mock.patch.object( self.base.service.client.stream, "send", @@ -1839,7 +1840,7 @@ def test_set_role_rejects_None_nick(self): with self.assertRaisesRegex(ValueError, "nick must not be None"): - run_coroutine(self.jmuc.set_role( + run_coroutine(self.jmuc.muc_set_role( None, "participant", reason="foobar", @@ -1847,7 +1848,7 @@ def test_set_role_rejects_None_nick(self): self.assertFalse(send_iq.mock_calls) - def test_set_role_rejects_None_role(self): + def test_muc_set_role_rejects_None_role(self): with unittest.mock.patch.object( self.base.service.client.stream, "send", @@ -1856,7 +1857,7 @@ def test_set_role_rejects_None_role(self): with self.assertRaisesRegex(ValueError, "role must not be None"): - run_coroutine(self.jmuc.set_role( + run_coroutine(self.jmuc.muc_set_role( "thirdwitch", None, reason="foobar", @@ -1864,7 +1865,7 @@ def test_set_role_rejects_None_role(self): self.assertFalse(send_iq.mock_calls) - def test_set_role_fails(self): + def test_muc_set_role_fails(self): with unittest.mock.patch.object( self.base.service.client.stream, "send", @@ -1875,20 +1876,20 @@ def test_set_role_fails(self): ) with self.assertRaises(aioxmpp.errors.XMPPCancelError): - run_coroutine(self.jmuc.set_role( + run_coroutine(self.jmuc.muc_set_role( "thirdwitch", "participant", reason="foobar", )) - def test_change_nick(self): + def test_set_nick(self): with unittest.mock.patch.object( self.base.service.client.stream, "send", new=CoroutineMock()) as send_stanza: send_stanza.return_value = None - run_coroutine(self.jmuc.change_nick( + run_coroutine(self.jmuc.set_nick( "oldhag", )) @@ -1907,14 +1908,14 @@ def test_change_nick(self): self.mucjid.replace(resource="oldhag"), ) - def test_set_affiliation_delegates_to_service(self): + def test_muc_set_affiliation_delegates_to_service(self): with unittest.mock.patch.object( self.base.service, "set_affiliation", new=CoroutineMock()) as set_affiliation: jid, aff, reason = object(), object(), object() - result = run_coroutine(self.jmuc.set_affiliation( + result = run_coroutine(self.jmuc.muc_set_affiliation( jid, aff, reason=reason )) @@ -1926,15 +1927,15 @@ def test_set_affiliation_delegates_to_service(self): ) self.assertEqual(result, run_coroutine(set_affiliation())) - def test_set_subject(self): + def test_set_topic(self): d = { None: "foobar" } - result = self.jmuc.set_subject(d) + result = run_coroutine(self.jmuc.set_topic(d)) _, (stanza,), _ = self.base.service.client.stream.\ - enqueue.mock_calls[-1] + send.mock_calls[-1] self.assertIsInstance( stanza, @@ -1955,16 +1956,13 @@ def test_set_subject(self): ) self.assertFalse(stanza.body) - self.assertEqual( - result, - self.base.service.client.stream.enqueue() - ) - - def test_leave(self): - self.jmuc.leave() + def test_leave_and_wait(self): + fut = asyncio.async(self.jmuc.leave()) + run_coroutine(asyncio.sleep(0)) + self.assertFalse(fut.done(), fut.done() and fut.result()) _, (stanza,), _ = self.base.service.client.stream.\ - enqueue.mock_calls[-1] + send.mock_calls[-1] self.assertIsInstance( stanza, @@ -1981,25 +1979,15 @@ def test_leave(self): self.assertFalse(stanza.status) self.assertEqual(stanza.show, aioxmpp.PresenceShow.NONE) - def test_leave_and_wait(self): - with unittest.mock.patch.object( - self.jmuc, - "leave") as leave: - fut = asyncio.async(self.jmuc.leave_and_wait()) - run_coroutine(asyncio.sleep(0)) - self.assertFalse(fut.done()) - - leave.assert_called_with() + self.jmuc.on_exit(muc_leave_mode=object(), + muc_actor=object(), + muc_reason=object()) - self.jmuc.on_exit(muc_leave_mode=object(), - muc_actor=object(), - muc_reason=object()) + self.assertIsNone(run_coroutine(fut)) - self.assertIsNone(run_coroutine(fut)) - - self.jmuc.on_exit(muc_leave_mode=object(), - muc_actor=object(), - muc_reason=object()) + self.jmuc.on_exit(muc_leave_mode=object(), + muc_actor=object(), + muc_reason=object()) def test_members(self): presence = aioxmpp.stanza.Presence( @@ -2063,8 +2051,8 @@ def test_members(self): self.assertIs(self.jmuc.members[0], self.jmuc.me) - def test_request_voice(self): - run_coroutine(self.jmuc.request_voice()) + def test_muc_request_voice(self): + run_coroutine(self.jmuc.muc_request_voice()) self.assertEqual( len(self.base.service.client.stream.send.mock_calls), @@ -2450,6 +2438,91 @@ def test_tracking_state_cleanup_on_close(self): aioxmpp.tracking.MessageState.IN_TRANSIT, ) + def test_ban_uses_set_affiliation(self): + member = unittest.mock.Mock(spec=muc_service.Occupant) + with unittest.mock.patch.object( + self.jmuc, + "muc_set_affiliation", + CoroutineMock()) as muc_set_affiliation: + run_coroutine(self.jmuc.ban( + member, + reason=unittest.mock.sentinel.reason + )) + + muc_set_affiliation.assert_called_once_with( + member.direct_jid, + "outcast", + reason=unittest.mock.sentinel.reason, + ) + + def test_ban_accepts_request_kick_argument(self): + member = unittest.mock.Mock(spec=muc_service.Occupant) + with unittest.mock.patch.object( + self.jmuc, + "muc_set_affiliation", + CoroutineMock()) as muc_set_affiliation: + run_coroutine(self.jmuc.ban( + member, + reason=unittest.mock.sentinel.reason, + request_kick=unittest.mock.sentinel.request_kick, + )) + + muc_set_affiliation.assert_called_once_with( + member.direct_jid, + "outcast", + reason=unittest.mock.sentinel.reason, + ) + + def test_ban_raises_ValueError_if_direct_jid_not_known(self): + member = unittest.mock.Mock(spec=muc_service.Occupant) + member.direct_jid = None + with unittest.mock.patch.object( + self.jmuc, + "muc_set_affiliation", + CoroutineMock()) as muc_set_affiliation: + with self.assertRaisesRegex( + ValueError, + "cannot ban members whose direct JID is not known"): + run_coroutine(self.jmuc.ban( + member, + reason=unittest.mock.sentinel.reason + )) + + muc_set_affiliation.assert_not_called() + + def test_kick_uses_muc_set_role(self): + member = unittest.mock.Mock(spec=muc_service.Occupant) + with unittest.mock.patch.object( + self.jmuc, + "muc_set_role", + CoroutineMock()) as muc_set_role: + run_coroutine(self.jmuc.kick( + member, + reason=unittest.mock.sentinel.reason + )) + + muc_set_role.assert_called_once_with( + member.nick, + "none", + reason=unittest.mock.sentinel.reason, + ) + + def test_features(self): + Feature = im_conversation.ConversationFeature + + self.assertSetEqual( + { + Feature.SET_NICK, + Feature.SET_TOPIC, + Feature.KICK, + Feature.BAN, + Feature.BAN_WITH_KICK, + Feature.SEND_MESSAGE, + Feature.SEND_MESSAGE_TRACKED, + }, + self.jmuc.features, + ) + class TestService(unittest.TestCase): def test_is_service(self): From fca55a8acdbf1f917d93400d817d112d05b5be24 Mon Sep 17 00:00:00 2001 From: Jonas Wielicki Date: Wed, 5 Apr 2017 21:03:14 +0200 Subject: [PATCH 27/40] im-muc: Rename properties and Signals on Room to adhere to AbstractConversation --- aioxmpp/muc/service.py | 26 +++--- tests/muc/test_e2e.py | 8 +- tests/muc/test_service.py | 176 +++++++++++++++++++------------------- 3 files changed, 105 insertions(+), 105 deletions(-) diff --git a/aioxmpp/muc/service.py b/aioxmpp/muc/service.py index 49a36864..38c9716d 100644 --- a/aioxmpp/muc/service.py +++ b/aioxmpp/muc/service.py @@ -394,17 +394,17 @@ class Room(aioxmpp.im.conversation.AbstractConversation): # this occupant state events on_enter = aioxmpp.callbacks.Signal() - on_suspend = aioxmpp.callbacks.Signal() - on_resume = aioxmpp.callbacks.Signal() + on_muc_suspend = aioxmpp.callbacks.Signal() + on_muc_resume = aioxmpp.callbacks.Signal() on_exit = aioxmpp.callbacks.Signal() # other occupant state events on_join = aioxmpp.callbacks.Signal() on_leave = aioxmpp.callbacks.Signal() on_presence_changed = aioxmpp.callbacks.Signal() - on_affiliation_change = aioxmpp.callbacks.Signal() + on_muc_affiliation_changed = aioxmpp.callbacks.Signal() on_nick_changed = aioxmpp.callbacks.Signal() - on_role_change = aioxmpp.callbacks.Signal() + on_muc_role_changed = aioxmpp.callbacks.Signal() # room state events on_topic_changed = aioxmpp.callbacks.Signal() @@ -429,7 +429,7 @@ def service(self): return self._service @property - def active(self): + def muc_active(self): """ A boolean attribute indicating whether the connection to the MUC is currently live. @@ -443,7 +443,7 @@ def active(self): return self._active @property - def joined(self): + def muc_joined(self): """ This attribute becomes true when :meth:`on_enter` is first emitted and stays true until :meth:`on_exit` is emitted. @@ -455,14 +455,14 @@ def joined(self): return self._joined @property - def subject(self): + def muc_subject(self): """ The current subject of the MUC, as :class:`~.structs.LanguageMap`. """ return self._subject @property - def subject_setter(self): + def muc_subject_setter(self): """ The nick name of the entity who set the subject. """ @@ -513,7 +513,7 @@ def features(self): } def _suspend(self): - self.on_suspend() + self.on_muc_suspend() self._active = False def _disconnect(self): @@ -529,7 +529,7 @@ def _resume(self): self._this_occupant = None self._occupant_info = {} self._active = False - self.on_resume() + self.on_muc_resume() def _match_tracker(self, message): try: @@ -641,7 +641,7 @@ def _diff_presence(self, stanza, info, existing): if existing.role != info.role: to_emit.append(( - self.on_role_change, + self.on_muc_role_changed, ( stanza, existing, @@ -654,7 +654,7 @@ def _diff_presence(self, stanza, info, existing): if existing.affiliation != info.affiliation: to_emit.append(( - self.on_affiliation_change, + self.on_muc_affiliation_changed, ( stanza, existing, @@ -1047,7 +1047,7 @@ def _stream_established(self): len(self._pending_mucs)) for muc, fut, nick, history in self._pending_mucs.values(): - if muc.joined: + if muc.muc_joined: self.logger.debug("%s: resuming", muc.jid) muc._resume() self.logger.debug("%s: sending join presence", muc.jid) diff --git a/tests/muc/test_e2e.py b/tests/muc/test_e2e.py index 659cf891..965428d5 100644 --- a/tests/muc/test_e2e.py +++ b/tests/muc/test_e2e.py @@ -322,8 +322,8 @@ def onleave(occupant, muc_leave_mode, **kwargs): yield from self.secondroom.leave() - self.assertFalse(self.secondroom.active) - self.assertFalse(self.secondroom.joined) + self.assertFalse(self.secondroom.muc_active) + self.assertFalse(self.secondroom.muc_joined) mode, = yield from exit_fut self.assertEqual( @@ -361,12 +361,12 @@ def onsubject(member, subject, **kwargs): ) self.assertDictEqual( - self.secondroom.subject, + self.secondroom.muc_subject, subject, ) self.assertEqual( - self.secondroom.subject_setter, + self.secondroom.muc_subject_setter, "firstwitch", ) diff --git a/tests/muc/test_service.py b/tests/muc/test_service.py index 189449b0..7c9d7d8d 100644 --- a/tests/muc/test_service.py +++ b/tests/muc/test_service.py @@ -295,10 +295,10 @@ def setUp(self): self.jmuc = muc_service.Room(self.base.service, self.mucjid) - for ev in ["on_enter", "on_exit", "on_suspend", "on_resume", + for ev in ["on_enter", "on_exit", "on_muc_suspend", "on_muc_resume", "on_message", "on_topic_changed", "on_join", "on_presence_changed", "on_nick_changed", - "on_role_change", "on_affiliation_change", + "on_muc_role_changed", "on_muc_affiliation_changed", "on_leave"]: cb = getattr(self.base, ev) cb.return_value = None @@ -329,7 +329,7 @@ def test_event_attributes(self): aioxmpp.callbacks.AdHocSignal ) self.assertIsInstance( - self.jmuc.on_affiliation_change, + self.jmuc.on_muc_affiliation_changed, aioxmpp.callbacks.AdHocSignal ) self.assertIsInstance( @@ -337,7 +337,7 @@ def test_event_attributes(self): aioxmpp.callbacks.AdHocSignal ) self.assertIsInstance( - self.jmuc.on_role_change, + self.jmuc.on_muc_role_changed, aioxmpp.callbacks.AdHocSignal ) self.assertIsInstance( @@ -345,22 +345,22 @@ def test_event_attributes(self): aioxmpp.callbacks.AdHocSignal ) self.assertIsInstance( - self.jmuc.on_suspend, + self.jmuc.on_muc_suspend, aioxmpp.callbacks.AdHocSignal ) self.assertIsInstance( - self.jmuc.on_resume, + self.jmuc.on_muc_resume, aioxmpp.callbacks.AdHocSignal ) def test_init(self): self.assertIs(self.jmuc.service, self.base.service) self.assertEqual(self.jmuc.jid, self.mucjid) - self.assertDictEqual(self.jmuc.subject, {}) - self.assertIsInstance(self.jmuc.subject, aioxmpp.structs.LanguageMap) - self.assertFalse(self.jmuc.joined) - self.assertFalse(self.jmuc.active) - self.assertIsNone(self.jmuc.subject_setter) + self.assertDictEqual(self.jmuc.muc_subject, {}) + self.assertIsInstance(self.jmuc.muc_subject, aioxmpp.structs.LanguageMap) + self.assertFalse(self.jmuc.muc_joined) + self.assertFalse(self.jmuc.muc_active) + self.assertIsNone(self.jmuc.muc_subject_setter) self.assertIsNone(self.jmuc.me) self.assertFalse(self.jmuc.autorejoin) self.assertIsNone(self.jmuc.password) @@ -375,19 +375,19 @@ def test_jid_is_not_writable(self): def test_active_is_not_writable(self): with self.assertRaises(AttributeError): - self.jmuc.active = True + self.jmuc.muc_active = True def test_subject_is_not_writable(self): with self.assertRaises(AttributeError): - self.jmuc.subject = "foo" + self.jmuc.muc_subject = "foo" def test_subject_setter_is_not_writable(self): with self.assertRaises(AttributeError): - self.jmuc.subject_setter = "bar" + self.jmuc.muc_subject_setter = "bar" def test_joined_is_not_writable(self): with self.assertRaises(AttributeError): - self.jmuc.joined = True + self.jmuc.muc_joined = True def test_me_is_not_writable(self): with self.assertRaises(AttributeError): @@ -410,22 +410,22 @@ def test__suspend_with_autorejoin(self): ) self.jmuc._inbound_muc_user_presence(presence) - self.assertTrue(self.jmuc.joined) - self.assertTrue(self.jmuc.active) + self.assertTrue(self.jmuc.muc_joined) + self.assertTrue(self.jmuc.muc_active) self.jmuc.autorejoin = True self.base.mock_calls.clear() self.jmuc._suspend() - self.assertTrue(self.jmuc.joined) - self.assertFalse(self.jmuc.active) + self.assertTrue(self.jmuc.muc_joined) + self.assertFalse(self.jmuc.muc_active) self.assertIsNotNone(self.jmuc.me) self.assertSequenceEqual( self.base.mock_calls, [ - unittest.mock.call.on_suspend(), + unittest.mock.call.on_muc_suspend(), ] ) @@ -446,22 +446,22 @@ def test__suspend_without_autorejoin(self): ) self.jmuc._inbound_muc_user_presence(presence) - self.assertTrue(self.jmuc.joined) - self.assertTrue(self.jmuc.active) + self.assertTrue(self.jmuc.muc_joined) + self.assertTrue(self.jmuc.muc_active) self.jmuc.autorejoin = False self.base.mock_calls.clear() self.jmuc._suspend() - self.assertFalse(self.jmuc.active) - self.assertTrue(self.jmuc.joined) + self.assertFalse(self.jmuc.muc_active) + self.assertTrue(self.jmuc.muc_joined) self.assertIsNotNone(self.jmuc.me) self.assertSequenceEqual( self.base.mock_calls, [ - unittest.mock.call.on_suspend(), + unittest.mock.call.on_muc_suspend(), ] ) @@ -479,16 +479,16 @@ def test__disconnect(self): ) self.jmuc._inbound_muc_user_presence(presence) - self.assertTrue(self.jmuc.joined) - self.assertTrue(self.jmuc.active) + self.assertTrue(self.jmuc.muc_joined) + self.assertTrue(self.jmuc.muc_active) self.jmuc.autorejoin = True self.base.mock_calls.clear() self.jmuc._disconnect() - self.assertFalse(self.jmuc.joined) - self.assertFalse(self.jmuc.active) + self.assertFalse(self.jmuc.muc_joined) + self.assertFalse(self.jmuc.muc_active) self.assertIsNotNone(self.jmuc.me) self.assertSequenceEqual( @@ -514,8 +514,8 @@ def test__disconnect_during_suspend(self): ) self.jmuc._inbound_muc_user_presence(presence) - self.assertTrue(self.jmuc.joined) - self.assertTrue(self.jmuc.active) + self.assertTrue(self.jmuc.muc_joined) + self.assertTrue(self.jmuc.muc_active) self.jmuc.autorejoin = True self.base.mock_calls.clear() @@ -524,14 +524,14 @@ def test__disconnect_during_suspend(self): self.jmuc._disconnect() - self.assertFalse(self.jmuc.joined) - self.assertFalse(self.jmuc.active) + self.assertFalse(self.jmuc.muc_joined) + self.assertFalse(self.jmuc.muc_active) self.assertIsNotNone(self.jmuc.me) self.assertSequenceEqual( self.base.mock_calls, [ - unittest.mock.call.on_suspend(), + unittest.mock.call.on_muc_suspend(), unittest.mock.call.on_exit( muc_leave_mode=muc_service.LeaveMode.DISCONNECTED ), @@ -539,16 +539,16 @@ def test__disconnect_during_suspend(self): ) def test__disconnect_is_noop_if_not_entered(self): - self.assertFalse(self.jmuc.joined) - self.assertFalse(self.jmuc.active) + self.assertFalse(self.jmuc.muc_joined) + self.assertFalse(self.jmuc.muc_active) self.jmuc.autorejoin = True self.base.mock_calls.clear() self.jmuc._disconnect() - self.assertFalse(self.jmuc.joined) - self.assertFalse(self.jmuc.active) + self.assertFalse(self.jmuc.muc_joined) + self.assertFalse(self.jmuc.muc_active) self.assertSequenceEqual( self.base.mock_calls, @@ -570,22 +570,22 @@ def test__suspend__resume_cycle(self): ) self.jmuc._inbound_muc_user_presence(presence) - self.assertTrue(self.jmuc.joined) - self.assertTrue(self.jmuc.active) + self.assertTrue(self.jmuc.muc_joined) + self.assertTrue(self.jmuc.muc_active) self.jmuc.autorejoin = True self.base.mock_calls.clear() self.jmuc._suspend() - self.assertTrue(self.jmuc.joined) - self.assertFalse(self.jmuc.active) + self.assertTrue(self.jmuc.muc_joined) + self.assertFalse(self.jmuc.muc_active) old_occupant = self.jmuc.me self.jmuc._resume() - self.assertTrue(self.jmuc.joined) - self.assertFalse(self.jmuc.active) + self.assertTrue(self.jmuc.muc_joined) + self.assertFalse(self.jmuc.muc_active) presence = aioxmpp.stanza.Presence( type_=aioxmpp.structs.PresenceType.AVAILABLE, @@ -600,14 +600,14 @@ def test__suspend__resume_cycle(self): ) self.jmuc._inbound_muc_user_presence(presence) - self.assertTrue(self.jmuc.active) + self.assertTrue(self.jmuc.muc_active) self.assertIsNot(old_occupant, self.jmuc.me) self.assertSequenceEqual( self.base.mock_calls, [ - unittest.mock.call.on_suspend(), - unittest.mock.call.on_resume(), + unittest.mock.call.on_muc_suspend(), + unittest.mock.call.on_muc_resume(), unittest.mock.call.on_enter(presence, self.jmuc.me) ] ) @@ -760,7 +760,7 @@ def test__inbound_muc_user_presence_emits_on_leave_for_kick(self): self.assertSequenceEqual( self.base.mock_calls, [ - unittest.mock.call.on_role_change( + unittest.mock.call.on_muc_role_changed( presence, first, actor=actor, @@ -833,13 +833,13 @@ def test__inbound_muc_user_presence_emits_on_leave_for_ban(self): self.assertSequenceEqual( self.base.mock_calls, [ - unittest.mock.call.on_role_change( + unittest.mock.call.on_muc_role_changed( presence, first, actor=actor, reason="Treason", ), - unittest.mock.call.on_affiliation_change( + unittest.mock.call.on_muc_affiliation_changed( presence, first, actor=actor, @@ -903,13 +903,13 @@ def test__inbound_muc_user_presence_emits_on_leave_for_affiliation_change( self.assertSequenceEqual( self.base.mock_calls, [ - unittest.mock.call.on_role_change( + unittest.mock.call.on_muc_role_changed( presence, first, actor=actor, reason="foo", ), - unittest.mock.call.on_affiliation_change( + unittest.mock.call.on_muc_affiliation_changed( presence, first, actor=actor, @@ -977,7 +977,7 @@ def test__inbound_muc_user_presence_emits_on_leave_for_moderation_change( self.assertSequenceEqual( self.base.mock_calls, [ - unittest.mock.call.on_role_change( + unittest.mock.call.on_muc_role_changed( presence, first, actor=actor, @@ -1308,11 +1308,11 @@ def test__inbound_muc_user_presence_emits_on_various_changes(self): None, presence, ), - unittest.mock.call.on_role_change( + unittest.mock.call.on_muc_role_changed( presence, first, actor=None, reason="foobar"), - unittest.mock.call.on_affiliation_change( + unittest.mock.call.on_muc_affiliation_changed( presence, first, actor=None, reason="foobar"), @@ -1355,7 +1355,7 @@ def test_handle_message_handles_subject_of_occupant(self): None: "foo" }) - old_subject = self.jmuc.subject + old_subject = self.jmuc.muc_subject self.jmuc._handle_message( msg, @@ -1365,12 +1365,12 @@ def test_handle_message_handles_subject_of_occupant(self): ) self.assertDictEqual( - self.jmuc.subject, + self.jmuc.muc_subject, msg.subject ) - self.assertIsNot(self.jmuc.subject, msg.subject) - self.assertIsNot(self.jmuc.subject, old_subject) - self.assertEqual(self.jmuc.subject_setter, msg.from_.resource) + self.assertIsNot(self.jmuc.muc_subject, msg.subject) + self.assertIsNot(self.jmuc.muc_subject, old_subject) + self.assertEqual(self.jmuc.muc_subject_setter, msg.from_.resource) self.assertSequenceEqual( self.base.mock_calls, @@ -1393,7 +1393,7 @@ def test_handle_message_handles_subject_of_non_occupant(self): None: "foo" }) - old_subject = self.jmuc.subject + old_subject = self.jmuc.muc_subject self.jmuc._handle_message( msg, @@ -1403,12 +1403,12 @@ def test_handle_message_handles_subject_of_non_occupant(self): ) self.assertDictEqual( - self.jmuc.subject, + self.jmuc.muc_subject, msg.subject ) - self.assertIsNot(self.jmuc.subject, msg.subject) - self.assertIsNot(self.jmuc.subject, old_subject) - self.assertEqual(self.jmuc.subject_setter, msg.from_.resource) + self.assertIsNot(self.jmuc.muc_subject, msg.subject) + self.assertIsNot(self.jmuc.muc_subject, old_subject) + self.assertEqual(self.jmuc.muc_subject_setter, msg.from_.resource) self.assertSequenceEqual( self.base.mock_calls, @@ -1440,15 +1440,15 @@ def test_handle_message_ignores_subject_if_body_is_present(self): ) self.assertDictEqual( - self.jmuc.subject, + self.jmuc.muc_subject, {} ) - self.assertIsNone(self.jmuc.subject_setter) + self.assertIsNone(self.jmuc.muc_subject_setter) self.base.on_topic_changed.assert_not_called() def test_handle_message_does_not_reset_subject_if_no_subject_given(self): - self.jmuc.subject[None] = "foo" + self.jmuc.muc_subject[None] = "foo" msg = aioxmpp.stanza.Message( from_=TEST_MUC_JID.replace(resource="secondwitch"), @@ -1463,12 +1463,12 @@ def test_handle_message_does_not_reset_subject_if_no_subject_given(self): ) self.assertDictEqual( - self.jmuc.subject, + self.jmuc.muc_subject, { None: "foo" } ) - self.assertIsNone(self.jmuc.subject_setter) + self.assertIsNone(self.jmuc.muc_subject_setter) self.base.on_topic_changed.assert_not_called() @@ -1591,8 +1591,8 @@ def test__inbound_muc_user_presence_emits_on_enter_and_on_exit(self): ) self.base.mock_calls.clear() - self.assertTrue(self.jmuc.joined) - self.assertTrue(self.jmuc.active) + self.assertTrue(self.jmuc.muc_joined) + self.assertTrue(self.jmuc.muc_active) self.assertIsInstance( self.jmuc.me, muc_service.Occupant @@ -1629,7 +1629,7 @@ def test__inbound_muc_user_presence_emits_on_enter_and_on_exit(self): ) ] ) - self.assertFalse(self.jmuc.joined) + self.assertFalse(self.jmuc.muc_joined) self.assertIsInstance( self.jmuc.me, muc_service.Occupant @@ -1641,7 +1641,7 @@ def test__inbound_muc_user_presence_emits_on_enter_and_on_exit(self): self.assertTrue( self.jmuc.me.is_self ) - self.assertFalse(self.jmuc.active) + self.assertFalse(self.jmuc.muc_active) def test_detect_self_presence_from_jid_if_status_is_missing(self): presence = aioxmpp.stanza.Presence( @@ -1691,7 +1691,7 @@ def test_detect_self_presence_from_jid_if_status_is_missing(self): ) ] ) - self.assertFalse(self.jmuc.joined) + self.assertFalse(self.jmuc.muc_joined) self.assertIsInstance( self.jmuc.me, muc_service.Occupant @@ -1703,7 +1703,7 @@ def test_detect_self_presence_from_jid_if_status_is_missing(self): self.assertTrue( self.jmuc.me.is_self ) - self.assertFalse(self.jmuc.active) + self.assertFalse(self.jmuc.muc_active) def test_do_not_treat_unavailable_stanzas_as_join(self): presence = aioxmpp.stanza.Presence( @@ -1770,8 +1770,8 @@ def test__inbound_muc_user_presence_ignores_self_leave_if_inactive(self): ) self.base.mock_calls.clear() - self.assertFalse(self.jmuc.joined) - self.assertFalse(self.jmuc.active) + self.assertFalse(self.jmuc.muc_joined) + self.assertFalse(self.jmuc.muc_active) self.assertIsNone(self.jmuc.me) def test_muc_set_role(self): @@ -3279,11 +3279,11 @@ def test_stream_destruction_with_autorejoin(self): room1.on_enter.connect(base.enter1) room2.on_enter.connect(base.enter2) - room1.on_suspend.connect(base.suspend1) - room2.on_suspend.connect(base.suspend2) + room1.on_muc_suspend.connect(base.suspend1) + room2.on_muc_suspend.connect(base.suspend2) - room1.on_resume.connect(base.resume1) - room2.on_resume.connect(base.resume2) + room1.on_muc_resume.connect(base.resume1) + room2.on_muc_resume.connect(base.resume2) room1.on_exit.connect(base.exit1) room2.on_exit.connect(base.exit2) @@ -3379,8 +3379,8 @@ def extract(items, op): ) base.mock_calls.clear() - self.assertFalse(room1.active) - self.assertFalse(room2.active) + self.assertFalse(room1.muc_active) + self.assertFalse(room2.muc_active) # now let both be joined presence = aioxmpp.stanza.Presence( @@ -3449,11 +3449,11 @@ def test_stream_destruction_without_autorejoin(self): room1.on_enter.connect(base.enter1) room2.on_enter.connect(base.enter2) - room1.on_suspend.connect(base.suspend1) - room2.on_suspend.connect(base.suspend2) + room1.on_muc_suspend.connect(base.suspend1) + room2.on_muc_suspend.connect(base.suspend2) - room1.on_resume.connect(base.resume1) - room2.on_resume.connect(base.resume2) + room1.on_muc_resume.connect(base.resume1) + room2.on_muc_resume.connect(base.resume2) room1.on_exit.connect(base.exit1) room2.on_exit.connect(base.exit2) From 28004c260ad785a395ea33c0b8563789a0a1fbc9 Mon Sep 17 00:00:00 2001 From: Jonas Wielicki Date: Thu, 6 Apr 2017 15:18:37 +0200 Subject: [PATCH 28/40] im-muc: Rename more MUC attributes to follow AbstractConversation --- aioxmpp/muc/service.py | 32 ++++++++++++++++---------------- tests/muc/test_service.py | 28 ++++++++++++++-------------- 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/aioxmpp/muc/service.py b/aioxmpp/muc/service.py index 38c9716d..5d4686cb 100644 --- a/aioxmpp/muc/service.py +++ b/aioxmpp/muc/service.py @@ -191,23 +191,23 @@ class Room(aioxmpp.im.conversation.AbstractConversation): .. autoattribute:: jid - .. autoattribute:: active + .. autoattribute:: muc_active - .. autoattribute:: joined + .. autoattribute:: muc_joined .. autoattribute:: me - .. autoattribute:: subject + .. autoattribute:: muc_subject - .. autoattribute:: subject_setter + .. autoattribute:: muc_subject_setter - .. attribute:: autorejoin + .. attribute:: muc_autorejoin A boolean flag indicating whether this MUC is supposed to be automatically rejoined when the stream it is used gets destroyed and re-estabished. - .. attribute:: password + .. attribute:: muc_password The password to use when (re-)joining. If :attr:`autorejoin` is :data:`None`, this can be cleared after :meth:`on_enter` has been @@ -224,11 +224,11 @@ class Room(aioxmpp.im.conversation.AbstractConversation): .. automethod:: request_voice - .. automethod:: set_role + .. automethod:: muc_set_role - .. automethod:: set_affiliation + .. automethod:: muc_set_affiliation - .. automethod:: set_subject + .. automethod:: set_topic The interface provides signals for most of the rooms events. The following keyword arguments are used at several signal handlers (which is also noted @@ -421,8 +421,8 @@ def __init__(self, service, mucjid): self._tracking_by_id = {} self._tracking_metadata = {} self._tracking_by_sender_body = {} - self.autorejoin = False - self.password = None + self.muc_autorejoin = False + self.muc_password = None @property def service(self): @@ -1051,7 +1051,7 @@ def _stream_established(self): self.logger.debug("%s: resuming", muc.jid) muc._resume() self.logger.debug("%s: sending join presence", muc.jid) - self._send_join_presence(muc.jid, history, nick, muc.password) + self._send_join_presence(muc.jid, history, nick, muc.muc_password) @aioxmpp.service.depsignal(aioxmpp.Client, "on_stream_destroyed") def _stream_destroyed(self): @@ -1061,7 +1061,7 @@ def _stream_destroyed(self): new_pending = {} for muc, fut, *more in self._pending_mucs.values(): - if not muc.autorejoin: + if not muc.muc_autorejoin: self.logger.debug( "%s: pending without autorejoin -> ConnectionError", muc.jid @@ -1076,7 +1076,7 @@ def _stream_destroyed(self): self._pending_mucs = new_pending for muc in list(self._joined_mucs.values()): - if muc.autorejoin: + if muc.muc_autorejoin: self.logger.debug( "%s: connected with autorejoin, suspending and adding to " "pending", @@ -1262,8 +1262,8 @@ def join(self, mucjid, nick, *, raise ValueError("already joined") room = Room(self, mucjid) - room.autorejoin = autorejoin - room.password = password + room.muc_autorejoin = autorejoin + room.muc_password = password room.on_exit.connect( functools.partial( self._muc_exited, diff --git a/tests/muc/test_service.py b/tests/muc/test_service.py index 7c9d7d8d..5ff70015 100644 --- a/tests/muc/test_service.py +++ b/tests/muc/test_service.py @@ -362,8 +362,8 @@ def test_init(self): self.assertFalse(self.jmuc.muc_active) self.assertIsNone(self.jmuc.muc_subject_setter) self.assertIsNone(self.jmuc.me) - self.assertFalse(self.jmuc.autorejoin) - self.assertIsNone(self.jmuc.password) + self.assertFalse(self.jmuc.muc_autorejoin) + self.assertIsNone(self.jmuc.muc_password) def test_service_is_not_writable(self): with self.assertRaises(AttributeError): @@ -413,7 +413,7 @@ def test__suspend_with_autorejoin(self): self.assertTrue(self.jmuc.muc_joined) self.assertTrue(self.jmuc.muc_active) - self.jmuc.autorejoin = True + self.jmuc.muc_autorejoin = True self.base.mock_calls.clear() self.jmuc._suspend() @@ -449,7 +449,7 @@ def test__suspend_without_autorejoin(self): self.assertTrue(self.jmuc.muc_joined) self.assertTrue(self.jmuc.muc_active) - self.jmuc.autorejoin = False + self.jmuc.muc_autorejoin = False self.base.mock_calls.clear() self.jmuc._suspend() @@ -482,7 +482,7 @@ def test__disconnect(self): self.assertTrue(self.jmuc.muc_joined) self.assertTrue(self.jmuc.muc_active) - self.jmuc.autorejoin = True + self.jmuc.muc_autorejoin = True self.base.mock_calls.clear() self.jmuc._disconnect() @@ -517,7 +517,7 @@ def test__disconnect_during_suspend(self): self.assertTrue(self.jmuc.muc_joined) self.assertTrue(self.jmuc.muc_active) - self.jmuc.autorejoin = True + self.jmuc.muc_autorejoin = True self.base.mock_calls.clear() self.jmuc._suspend() @@ -542,7 +542,7 @@ def test__disconnect_is_noop_if_not_entered(self): self.assertFalse(self.jmuc.muc_joined) self.assertFalse(self.jmuc.muc_active) - self.jmuc.autorejoin = True + self.jmuc.muc_autorejoin = True self.base.mock_calls.clear() self.jmuc._disconnect() @@ -573,7 +573,7 @@ def test__suspend__resume_cycle(self): self.assertTrue(self.jmuc.muc_joined) self.assertTrue(self.jmuc.muc_active) - self.jmuc.autorejoin = True + self.jmuc.muc_autorejoin = True self.base.mock_calls.clear() self.jmuc._suspend() @@ -2745,8 +2745,8 @@ def test_join_without_password_or_history(self): self.s.get_muc(TEST_MUC_JID), room ) - self.assertTrue(room.autorejoin) - self.assertIsNone(room.password) + self.assertTrue(room.muc_autorejoin) + self.assertIsNone(room.muc_password) _, (stanza,), _ = self.cc.stream.enqueue.mock_calls[-1] self.assertIsInstance( @@ -2785,8 +2785,8 @@ def test_join_with_password(self): self.s.get_muc(TEST_MUC_JID), room ) - self.assertTrue(room.autorejoin) - self.assertEqual(room.password, "foobar") + self.assertTrue(room.muc_autorejoin) + self.assertEqual(room.muc_password, "foobar") self.assertIs( self.s.get_muc(TEST_MUC_JID), @@ -2830,8 +2830,8 @@ def test_join_without_autorejoin_with_password(self): self.s.get_muc(TEST_MUC_JID), room ) - self.assertFalse(room.autorejoin) - self.assertEqual(room.password, "foobar") + self.assertFalse(room.muc_autorejoin) + self.assertEqual(room.muc_password, "foobar") _, (stanza,), _ = self.cc.stream.enqueue.mock_calls[-1] self.assertIsInstance( From 855b16f44e2399c669eb586d97c0438c1d4c1f30 Mon Sep 17 00:00:00 2001 From: Jonas Wielicki Date: Thu, 6 Apr 2017 19:31:31 +0200 Subject: [PATCH 29/40] p2p-im: Adapt to changes in interface --- aioxmpp/im/p2p.py | 3 +-- tests/im/test_p2p.py | 8 +++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/aioxmpp/im/p2p.py b/aioxmpp/im/p2p.py index a58975cb..9f4c4b6e 100644 --- a/aioxmpp/im/p2p.py +++ b/aioxmpp/im/p2p.py @@ -73,11 +73,10 @@ def members(self): def me(self): return self.__members[0] - @asyncio.coroutine def send_message(self, msg): msg.to = self.__peer_jid self.on_message(msg, self.me, MessageSource.STREAM) - yield from self._client.stream.send(msg) + return self._client.stream.enqueue(msg) @asyncio.coroutine def send_message_tracked(self, msg): diff --git a/tests/im/test_p2p.py b/tests/im/test_p2p.py index 747db254..b14da982 100644 --- a/tests/im/test_p2p.py +++ b/tests/im/test_p2p.py @@ -94,11 +94,11 @@ def test_me(self): self.c.me.is_self ) - def test_send_message_stamps_to_and_sends(self): + def test_send_message_stamps_to_and_enqueues(self): msg = unittest.mock.Mock() - run_coroutine(self.c.send_message(msg)) + token = self.c.send_message(msg) - self.cc.stream.send.assert_called_once_with(msg) + self.cc.stream.enqueue.assert_called_once_with(msg) self.assertEqual(msg.to, PEER_JID) self.listener.on_message.assert_called_once_with( @@ -107,6 +107,8 @@ def test_send_message_stamps_to_and_sends(self): im_dispatcher.MessageSource.STREAM, ) + self.assertEqual(token, self.cc.stream.enqueue()) + def test_inbound_message_dispatched_to_event(self): msg = unittest.mock.sentinel.message self.c._handle_message( From 63beac2ae721bbfe1f24e20a905e0b1802adb36b Mon Sep 17 00:00:00 2001 From: Jonas Wielicki Date: Fri, 7 Apr 2017 14:18:16 +0200 Subject: [PATCH 30/40] im: Polishing of the AbstractConversation API --- aioxmpp/im/conversation.py | 40 ++++++++++++++++++----------------- aioxmpp/im/p2p.py | 2 +- tests/im/test_conversation.py | 19 ++++++++--------- 3 files changed, 31 insertions(+), 30 deletions(-) diff --git a/aioxmpp/im/conversation.py b/aioxmpp/im/conversation.py index e6561329..5ea0b261 100644 --- a/aioxmpp/im/conversation.py +++ b/aioxmpp/im/conversation.py @@ -347,8 +347,8 @@ class AbstractConversation(metaclass=abc.ABCMeta): .. note:: In some implementations, unavailable presence implies that a - participant leaves the room, in which case :meth:`on_leave` is also - emitted. + participant leaves the room, in which case :meth:`on_leave` is + emitted instead. .. signal:: on_nick_changed(member, old_nick, new_nick, **kwargs) @@ -524,7 +524,8 @@ def send_message(self, body): """ Send a message to the conversation. - :param body: The message body. + :param msg: The message to send. + :type msg: :class:`aioxmpp.Message` :return: The stanza token obtained from sending. :rtype: :class:`~aioxmpp.stream.StanzaToken` @@ -532,6 +533,12 @@ def send_message(self, body): and immediately cancels the tracking object, returning only the stanza token. + There is no need to provide proper address attributes on `msg`. + Implementations will override those attributes with the values + appropriate for the conversation. Some implementations may allow the + user to choose a :attr:`~aioxmpp.Message.type_`, but others may simply + stamp it over. + Subclasses may override this method with a more specialised implementation. Subclasses which do not provide tracked message sending **must** override this method to provide untracked message sending. @@ -543,15 +550,17 @@ def send_message(self, body): details. """ - tracker = yield from self.send_message_tracked(body) + token, tracker = yield from self.send_message_tracked(body) tracker.cancel() + return token @abc.abstractmethod - def send_message_tracked(self, body, *, timeout=None): + def send_message_tracked(self, msg, *, timeout=None): """ Send a message to the conversation with tracking. - :param body: The message body. + :param msg: The message to send. + :type msg: :class:`aioxmpp.Message` :param timeout: Timeout for the tracking. :type timeout: :class:`numbers.RealNumber`, :class:`datetime.timedelta` or :data:`None` @@ -560,6 +569,12 @@ def send_message_tracked(self, body, *, timeout=None): :class:`aioxmpp.tracking.MessageTracker` tracking the delivery. :rtype: :class:`~aioxmpp.stream.StanzaToken` + There is no need to provide proper address attributes on `msg`. + Implementations will override those attributes with the values + appropriate for the conversation. Some implementations may allow the + user to choose a :attr:`~aioxmpp.Message.type_`, but others may simply + stamp it over. + Tracking may not be supported by all implementations, and the degree of support varies with implementation. Please check the documentation of the respective subclass. @@ -726,32 +741,19 @@ def set_topic(self, new_topic): """ raise self._not_implemented_error("changing the topic") - @abc.abstractmethod @asyncio.coroutine def leave(self): """ Leave the conversation. - The base implementation calls - :meth:`.AbstractConversationService._conversation_left` and must be - called after all other preconditions for a leave have completed. - .. seealso:: The corresponding feature is :attr:`.ConversationFeature.LEAVE`. See :attr:`features` for details. """ - self._service._conversation_left(self) class AbstractConversationService(metaclass=abc.ABCMeta): on_conversation_new = aioxmpp.callbacks.Signal() on_conversation_left = aioxmpp.callbacks.Signal() - - @abc.abstractmethod - def _conversation_left(self, c): - """ - Called by :class:`AbstractConversation` after the conversation has been - left by the client. - """ diff --git a/aioxmpp/im/p2p.py b/aioxmpp/im/p2p.py index 9f4c4b6e..964589f3 100644 --- a/aioxmpp/im/p2p.py +++ b/aioxmpp/im/p2p.py @@ -84,7 +84,7 @@ def send_message_tracked(self, msg): @asyncio.coroutine def leave(self): - yield from super().leave() + self._service._conversation_left(self) class Service(AbstractConversationService, aioxmpp.service.Service): diff --git a/tests/im/test_conversation.py b/tests/im/test_conversation.py index 3affc18c..61d766be 100644 --- a/tests/im/test_conversation.py +++ b/tests/im/test_conversation.py @@ -51,10 +51,6 @@ def jid(self): def send_message_tracked(self, *args, **kwargs): return self.__mock.send_message_tracked(*args, **kwargs) - @asyncio.coroutine - def leave(self): - yield from super().leave() - class TestConversation(unittest.TestCase): def setUp(self): @@ -92,13 +88,16 @@ def test_parent_is_not_writable(self): with self.assertRaises(AttributeError): self.c.parent = self.c.parent - def test_leave_calls_conversation_left_on_service(self): - run_coroutine(self.c.leave()) - self.svc._conversation_left.assert_called_once_with(self.c) - def test_send_message_calls_send_message_tracked_and_cancels_tracking(self): - run_coroutine(self.c.send_message(unittest.mock.sentinel.body)) + token = unittest.mock.Mock() + tracker = unittest.mock.Mock() + + self.c_mock.send_message_tracked.return_value = token, tracker + + result = run_coroutine(self.c.send_message(unittest.mock.sentinel.body)) self.c_mock.send_message_tracked.assert_called_once_with( unittest.mock.sentinel.body, ) - self.c_mock.send_message_tracked().cancel.assert_called_once_with() + + tracker.cancel.assert_called_once_with() + self.assertEqual(result, token) From c382b19b185d0329458c6deb2fe8ac8e189d435b Mon Sep 17 00:00:00 2001 From: Jonas Wielicki Date: Thu, 6 Apr 2017 15:19:02 +0200 Subject: [PATCH 31/40] im-muc: Docs update and minor API alignment fixes --- aioxmpp/im/conversation.py | 19 +- aioxmpp/muc/__init__.py | 6 + aioxmpp/muc/service.py | 508 +++++++++++++++++++++++++++---------- tests/muc/test_service.py | 43 +++- 4 files changed, 431 insertions(+), 145 deletions(-) diff --git a/aioxmpp/im/conversation.py b/aioxmpp/im/conversation.py index 5ea0b261..6f23f27a 100644 --- a/aioxmpp/im/conversation.py +++ b/aioxmpp/im/conversation.py @@ -375,7 +375,7 @@ class AbstractConversation(metaclass=abc.ABCMeta): :param member: The member object who changed the topic. :type member: :class:`~.AbstractConversationMember` :param new_topic: The new topic of the conversation. - :type new_topic: :class:`str` + :type new_topic: :class:`.LanguageMap` .. signal:: on_join(member, **kwargs) @@ -600,10 +600,11 @@ def send_message_tracked(self, msg, *, timeout=None): @asyncio.coroutine def kick(self, member, reason=None): """ - Kick a member from a conversation. + Kick a member from the conversation. :param member: The member to kick. - :param reason: A reason to show to the kicked member. + :param reason: A reason to show to the members of the conversation + including the kicked member. :type reason: :class:`str` :raises aioxmpp.errors.XMPPError: if the server returned an error for the kick command. @@ -618,11 +619,15 @@ def kick(self, member, reason=None): @asyncio.coroutine def ban(self, member, reason=None, *, request_kick=True): """ - Ban a member from re-joining a conversation. + Ban a member from re-joining the conversation. :param member: The member to ban. - :param reason: A reason to show to the banned member. + :param reason: A reason to show to the members of the conversation + including the banned member. :type reason: :class:`str` + :param request_kick: A flag indicating that the member should be + removed from the conversation immediately, too. + :type request_kick: :class:`bool` If `request_kick` is true, the implementation attempts to kick the member from the conversation, too, if that does not happen @@ -682,8 +687,8 @@ def invite(self, jid, *, .. seealso:: The corresponding feature for this method is - :attr:`.ConversationFeature.INVITE`. See :attr:`features` for details - on the semantics of features. + :attr:`.ConversationFeature.INVITE`. See :attr:`features` for + details on the semantics of features. """ raise self._not_implemented_error("inviting entities") diff --git a/aioxmpp/muc/__init__.py b/aioxmpp/muc/__init__.py index eece567e..bbc7f409 100644 --- a/aioxmpp/muc/__init__.py +++ b/aioxmpp/muc/__init__.py @@ -27,6 +27,12 @@ .. versionadded:: 0.5 +.. versionchanged:: 0.9 + + Nearly the whole public interface of this module has been re-written in + 0.9 to make it coherent with the Modern IM interface defined by + :class:`aioxmpp.im`. + Using Multi-User-Chats ====================== diff --git a/aioxmpp/muc/service.py b/aioxmpp/muc/service.py index 5d4686cb..5adc73dc 100644 --- a/aioxmpp/muc/service.py +++ b/aioxmpp/muc/service.py @@ -187,16 +187,37 @@ def update(self, other): class Room(aioxmpp.im.conversation.AbstractConversation): """ - Interface to a :xep:`0045` multi-user-chat room. + :term:`Conversation` representing a single :xep:`45` Multi-User Chat. + + .. note:: + + This is an implementation of :class:`~.AbstractConversation`. The + members which do not carry the ``muc_`` prefix usually have more + extensive documentation there. This documentation here only provides + a short synopsis for those members plus the changes with respect to + the base interface. + + .. versionchanged:: 0.9 + + In 0.9, the :class:`Room` interface was re-designed to match + :class:`~.AbstractConversation`. + + The following properties are provided: + + .. autoattribute:: features .. autoattribute:: jid + .. autoattribute:: me + + .. autoattribute:: members + + These properties are specific to MUC: + .. autoattribute:: muc_active .. autoattribute:: muc_joined - .. autoattribute:: me - .. autoattribute:: muc_subject .. autoattribute:: muc_subject_setter @@ -216,154 +237,210 @@ class Room(aioxmpp.im.conversation.AbstractConversation): The following methods and properties provide interaction with the MUC itself: - .. autoattribute:: members + .. automethod:: ban - .. automethod:: set_nick + .. automethod:: kick .. automethod:: leave - .. automethod:: request_voice + .. automethod:: send_message - .. automethod:: muc_set_role + .. automethod:: send_message_tracked - .. automethod:: muc_set_affiliation + .. automethod:: set_nick .. automethod:: set_topic + .. automethod:: muc_request_voice + + .. automethod:: muc_set_role + + .. automethod:: muc_set_affiliation + The interface provides signals for most of the rooms events. The following keyword arguments are used at several signal handlers (which is also noted at their respective documentation): - `actor` = :data:`None` - The :class:`UserActor` instance of the corresponding :class:`UserItem`, - describing which other occupant caused the event. + `muc_actor` = :data:`None` + The :class:`~.xso.UserActor` instance of the corresponding + :class:`~.xso.UserExt`, describing which other occupant caused the + event. - `reason` = :data:`None` - The reason text in the corresponding :class:`UserItem`, which gives more - information on why an action was triggered. + Note that the `muc_actor` is in fact not a :class:`~.Occupant`. - `occupant` = :data:`None` - The :class:`Occupant` object tracking the subject of the operation. + `muc_reason` = :data:`None` + The reason text in the corresponding :class:`~.xso.UserExt`, which + gives more information on why an action was triggered. .. note:: Signal handlers attached to any of the signals below **must** accept - arbitrary keyword arguments for forward compatibility. If any of the - above arguments is listed as positional in the signal signature, it is - always present and handed as positional argument. + arbitrary keyword arguments for forward compatibility. For details see + the documentation on :class:`~.AbstractConversation`. - .. signal:: on_message(message, **kwargs) + .. signal:: on_enter(presence, occupant, **kwargs) - Emits when a group chat :class:`~.Message` `message` is - received for the room. This is also emitted on messages sent by the - local user; this allows tracking when a message has been spread to all - users in the room. + Emits when the initial room :class:`~.Presence` stanza for the + local JID is received. This means that the join to the room is complete; + the message history and subject are not transferred yet though. - The signal also emits during history playback from the server. + The `occupant` argument refers to the :class:`Occupant` which will be + used to track the local user. - The `occupant` argument refers to the sender of the message, if presence - has been broadcast for the sender. There are two cases where this might - not be the case: + .. signal:: on_message(msg, member, source, **kwargs) - 1. if the signal emits during history playback, there might be no - occupant with the given nick anymore. + A message occured in the conversation. - 2. if the room is configured to not emit presence for occupants in - certain roles, no :class:`Occupant` instances are created and tracked - for those occupants + :param msg: Message which was received. + :type msg: :class:`aioxmpp.Message` + :param member: The member object of the sender. + :type member: :class:`.AbstractConversationMember` + :param source: How the message was acquired + :type source: :class:`~.MessageSource` - .. signal:: on_subject_change(message, subject, **kwargs) + The notable specialities about MUCs compared to the base specification + at :meth:`.AbstractConversation.on_message` are: - Emits when the subject of the room changes or is transmitted initially. + * Carbons do not happen for MUC messages. + * MUC Private Messages are not handled here; see :class:`MUCClient` for + MUC PM details. + * MUCs reflect messages; to make this as easy to handle as possible, + reflected messages are **not** emitted via the :meth:`on_message` + event **if and only if** they were sent with tracking (see + :meth:`send_message_tracked`) and they were detected as reflection. - `subject` is the new subject, as a :class:`~.structs.LanguageMap`. + See :meth:`send_message_tracked` for details and caveats on the + tracking implementation. - The `occupant` keyword argument refers to the sender of the message, and - thus the entity who changed the subject. If the message represents the - current subject of the room on join, the `occupant` may be :data:`None` - if the entity who has set the subject is not in the room - currently. Likewise, the `occupant` may indeed refer to an entirely - different person, as the nick name may have changed owners between the - setting of the subject and the join to the room. + .. seealso:: - .. signal:: on_enter(presence, occupant, **kwargs) + :meth:`.AbstractConversation.on_message` for the full + specification. - Emits when the initial room :class:`~.Presence` stanza for the - local JID is received. This means that the join to the room is complete; - the message history and subject are not transferred yet though. + .. signal:: on_presence_changed(member, resource, presence, **kwargs) - The `occupant` argument refers to the :class:`Occupant` which will be - used to track the local user. + The presence state of an occupant has changed. - .. signal:: on_suspend() + :param member: The member object of the affected member. + :type member: :class:`Occupant` + :param resource: The resource of the member which changed presence. + :type resource: :class:`str` or :data:`None` + :param presence: The presence stanza + :type presence: :class:`aioxmpp.Presence` - Emits when the stream used by this MUC gets destroyed (see - :meth:`~.node.Client.on_stream_destroyed`) and the MUC is configured to - automatically rejoin the user when the stream is re-established. + `resource` is always :data:`None` for MUCs and unavailable presence + implies that the occupant left the room. In this case, only + :meth:`on_leave` is emitted. - .. signal:: on_resume() + .. seealso:: - Emits when the MUC is about to be rejoined on a new stream. This can be - used by implementations to clear their MUC state, as it is emitted - *before* any events like presence are emitted. + :meth:`.AbstractConversation.on_presence_changed` for the full + specification. - The internal state of :class:`Room` is cleared before :meth:`on_resume` - is emitted, which implies that presence events will be emitted for all - occupants on re-join, independent on their presence before the - connection was lost. + .. signal:: on_nick_changed(member, old_nick, new_nick, **kwargs) - Note that on a rejoin, all presence is re-emitted. + The nickname of an occupant has changed - .. signal:: on_exit(presence, occupant, mode, **kwargs) + :param member: The occupant whose nick has changed. + :type member: :class:`Occupant` + :param old_nick: The old nickname of the member. + :type old_nick: :class:`str` or :data:`None` + :param new_nick: The new nickname of the member. + :type new_nick: :class:`str` - Emits when the unavailable :class:`~.Presence` stanza for the - local JID is received. + The new nickname is already set in the `member` object. Both `old_nick` + and `new_nick` are not :data:`None`. - `mode` indicates how the occupant got removed from the room, see the - :class:`LeaveMode` enumeration for possible values. + .. seealso:: - The `occupant` argument refers to the :class:`Occupant` which - is used to track the local user. If given in the stanza, the `actor` - and/or `reason` keyword arguments are provided. + :meth:`.AbstractConversation.on_nick_changed` for the full + specification. - If :attr:`autorejoin` is false and the stream gets destroyed, or if the - :class:`.MUCClient` is unloaded from a node, this event emits with - `presence` set to :data:`None`. + .. signal:: on_topic_changed(member, new_topic, **kwargs) - The following signals inform users about state changes related to **other** - occupants in the chat room. Note that different events may fire for the - same presence stanza. A common example is a ban, which triggers - :meth:`on_affiliation_change` (as the occupants affiliation is set to - ``"outcast"``) and then :meth:`on_leave` (with :attr:`LeaveMode.BANNED` - `mode`). + The topic of the conversation has changed. + + :param member: The member object who changed the topic. + :type member: :class:`Occupant` or :data:`None` + :param new_topic: The new topic of the conversation. + :type new_topic: :class:`.LanguageMap` + + The `member` is matched by nickname. It is possible that the member is + not in the room at the time the topic chagne is received (for example + on a join). + + .. note:: + + :meth:`on_topic_changed` is emitted during join, iff a topic is set + in the MUC. + + .. signal:: on_join(member, **kwargs) - .. signal:: on_join(presence, occupant, **kwargs) + A new occupant has joined the MUC. - Emits when a new occupant enters the room. `occupant` refers to the new - :class:`Occupant` object which tracks the occupant. The object will be - indentical for all events related to that occupant, but its contents - will change accordingly. + :param member: The member object of the new member. + :type member: :class:`Occupant` - The original :class:`~.Presence` stanza which announced the join - of the occupant is given as `presence`. + When this signal is called, the `member` is already included in the + :attr:`members`. - .. signal:: on_leave(presence, occupant, mode, **kwargs) + .. signal:: on_leave(member, *, muc_leave_mode=None, muc_actor=None, muc_reason=None, **kwargs) - Emits when an occupant leaves the room. + An occupant has left the conversation. - `occupant` is the :class:`Occupant` instance tracking the occupant which - just left the room. + :param member: The member object of the previous occupant. + :type member: :class:`Occupant` + :param muc_leave_mode: The cause of the removal. + :type muc_leave_mode: :class:`LeaveMode` member + :param muc_actor: The actor object if available. + :type muc_actor: :class:`~.xso.UserActor` + :param muc_reason: The reason for the cause, as given by the actor. + :type muc_reason: :class:`str` - `mode` indicates how the occupant got removed from the room, see the - :class:`LeaveMode` enumeration for possible values. + When this signal is called, the `member` has already been removed from + the :attr:`members`. - If the `mode` is not :attr:`LeaveMode.NORMAL`, there may be `actor` - and/or `reason` keyword arguments which provide details on who triggered - the leave and for what reason. + .. signal:: on_muc_suspend() - .. signal:: on_affiliation_change(presence, occupant, **kwargs) + Emits when the stream used by this MUC gets destroyed (see + :meth:`~.node.Client.on_stream_destroyed`) and the MUC is configured to + automatically rejoin the user when the stream is re-established. - Emits when the affiliation of an `occupant` with the room changes. + .. signal:: on_muc_resume() + + Emits when the MUC is about to be rejoined on a new stream. This can be + used by implementations to clear their MUC state, as it is emitted + *before* any events like presence are emitted. + + The internal state of :class:`Room` is cleared before :meth:`on_resume` + is emitted, which implies that presence events will be emitted for all + occupants on re-join, independent on their presence before the + connection was lost. + + Note that on a rejoin, all presence is re-emitted. + + .. signal:: on_exit(*, muc_leave_mode=None, muc_actor=None, muc_reason=None, **kwargs) + + Emits when the unavailable :class:`~.Presence` stanza for the + local JID is received. + + :param muc_leave_mode: The cause of the removal. + :type muc_leave_mode: :class:`LeaveMode` member + :param muc_actor: The actor object if available. + :type muc_actor: :class:`~.xso.UserActor` + :param muc_reason: The reason for the cause, as given by the actor. + :type muc_reason: :class:`str` + + The following signals inform users about state changes related to **other** + occupants in the chat room. Note that different events may fire for the + same presence stanza. A common example is a ban, which triggers + :meth:`on_affiliation_change` (as the occupants affiliation is set to + ``"outcast"``) and then :meth:`on_leave` (with :attr:`LeaveMode.BANNED` + `mode`). + + .. signal:: on_muc_affiliation_changed(member, *, muc_actor=None, muc_reason=None, **kwargs) + + Emits when the affiliation of a `member` with the room changes. `occupant` is the :class:`Occupant` instance tracking the occupant whose affiliation changed. @@ -371,7 +448,7 @@ class Room(aioxmpp.im.conversation.AbstractConversation): There may be `actor` and/or `reason` keyword arguments which provide details on who triggered the change in affiliation and for what reason. - .. signal:: on_role_change(presence, occupant, **kwargs) + .. signal:: on_muc_role_changed(member, *, muc_actor=None, muc_reason=None, **kwargs) Emits when the role of an `occupant` in the room changes. @@ -381,13 +458,6 @@ class Room(aioxmpp.im.conversation.AbstractConversation): There may be `actor` and/or `reason` keyword arguments which provide details on who triggered the change in role and for what reason. - .. signal:: on_nick_change(presence, occupant, **kwargs) - - Emits when the nick name (room name) of an `occupant` changes. - - `occupant` is the :class:`Occupant` instance tracking the occupant whose - status changed. - """ on_message = aioxmpp.callbacks.Signal() @@ -502,6 +572,12 @@ def members(self): @property def features(self): + """ + The set of features supported by this MUC. This may vary depending on + features expotred by the MUC service, so be sure to check this for each + individual MUC. + """ + return { aioxmpp.im.conversation.ConversationFeature.BAN, aioxmpp.im.conversation.ConversationFeature.BAN_WITH_KICK, @@ -766,6 +842,23 @@ def _inbound_muc_user_presence(self, stanza): @asyncio.coroutine def send_message(self, msg): + """ + Send a message to the MUC. + + :param msg: The message to send. + :type msg: :class:`aioxmpp.Message` + + There is no need to set the address attributes or the type of the + message correctly; those will be overridden by this method to conform + to the requirements of a message to the MUC. Other attributes are left + untouched (except that :meth:`~.StanzaBase.autoset_id` is called) and + can be used as desired for the message. + + .. seealso:: + + :meth:`.AbstractConversation.send_message` for the full interface + specification. + """ msg.type_ = aioxmpp.MessageType.GROUPCHAT msg.to = self._mucjid msg.xep0045_muc_user = muc_xso.UserExt() @@ -786,6 +879,59 @@ def _tracker_closed(self, tracker): @asyncio.coroutine def send_message_tracked(self, msg): + """ + Send a message to the MUC with tracking. + + :param msg: The message to send. + :type msg: :class:`aioxmpp.Message` + + .. warning:: + + Please read :ref:`api-tracking-memory`. This is especially relevant + for MUCs because tracking is not guaranteed to work due to how + :xep:`45` is written. It will work in many cases, probably in all + cases you test during development, but it may fail to work for some + individual messages and it may fail to work consistently for some + services. See the implementation details below for reasons. + + The message is tracked and is considered + :attr:`~.MessageState.DELIVERED_TO_RECIPIENT` when it is reflected back + to us by the MUC service. The reflected message is then available in + the :attr:`~.MessageTracker.response` attribute. + + .. note:: + + Two things: + + 1. The MUC service may change the contents of the message. An + example of this is the Prosody developer MUC which replaces + messages with more than a few lines with a pastebin link. + 2. Reflected messages which are caught by tracking are not emitted + through :meth:`on_message`. + + There is no need to set the address attributes or the type of the + message correctly; those will be overridden by this method to conform + to the requirements of a message to the MUC. Other attributes are left + untouched (except that :meth:`~.StanzaBase.autoset_id` is called) and + can be used as desired for the message. + + .. seealso:: + + :meth:`.AbstractConversation.send_message_tracked` for the full + interface specification. + + **Implementation details:** Currently, we try to detect reflected + messages using two different criteria. First, if we see a message with + the same message ID (note that message IDs contain 120 bits of entropy) + as the message we sent, we consider it as the reflection. As some MUC + services re-write the message ID in the reflection, as a fallback, we + also consider messages which originate from the correct sender and have + the correct body a reflection. + + Obviously, this fails consistently in MUCs which re-write the body and + re-write the ID and randomly if the MUC always re-writes the ID but only + sometimes the body. + """ msg.type_ = aioxmpp.MessageType.GROUPCHAT msg.to = self._mucjid msg.xep0045_muc_user = muc_xso.UserExt() @@ -821,8 +967,13 @@ def set_nick(self, new_nick): This sends the request to change the nickname and waits for the request to be sent over the stream. - The nick change may or may not happen; observe the - :meth:`on_nick_change` event. + The nick change may or may not happen, or the service may modify the + nickname; observe the :meth:`on_nick_change` event. + + .. seealso:: + + :meth:`.AbstractConversation.set_nick` for the full interface + specification. """ stanza = aioxmpp.Presence( @@ -835,6 +986,22 @@ def set_nick(self, new_nick): @asyncio.coroutine def kick(self, member, reason=None): + """ + Kick an occupant from the MUC. + + :param member: The member to kick. + :type member: :class:`Occupant` + :param reason: A reason to show to the members of the conversation + including the kicked member. + :type reason: :class:`str` + :raises aioxmpp.errors.XMPPError: if the server returned an error for + the kick command. + + .. seealso:: + + :meth:`.AbstractConversation.kick` for the full interface + specification. + """ yield from self.muc_set_role( member.nick, "none", @@ -844,6 +1011,15 @@ def kick(self, member, reason=None): @asyncio.coroutine def muc_set_role(self, nick, role, *, reason=None): """ + Change the role of an occupant. + + :param nick: The nickname of the occupant whose role shall be changed. + :type nick: :class:`str` + :param role: The new role for the occupant. + :type role: :class:`str` + :param reason: An optional reason to show to the occupant (and all + others). + Change the role of an occupant, identified by their `nick`, to the given new `role`. Optionally, a `reason` for the role change can be provided. @@ -852,7 +1028,7 @@ def muc_set_role(self, nick, role, *, reason=None): user. The details can be checked in :xep:`0045` and are enforced solely by the server, not local code. - The coroutine returns when the kick has been acknowledged by the + The coroutine returns when the role change has been acknowledged by the server. If the server returns an error, an appropriate :class:`aioxmpp.errors.XMPPError` subclass is raised. """ @@ -882,6 +1058,26 @@ def muc_set_role(self, nick, role, *, reason=None): @asyncio.coroutine def ban(self, member, reason=None, *, request_kick=True): + """ + Ban an occupant from re-joining the MUC. + + :param member: The occupant to ban. + :type member: :class:`Occupant` + :param reason: A reason to show to the members of the conversation + including the banned member. + :type reason: :class:`str` + :param request_kick: A flag indicating that the member should be + removed from the conversation immediately, too. + :type request_kick: :class:`bool` + + `request_kick` is supported by MUC, but setting it to false has no + effect: banned members are always immediately kicked. + + .. seealso:: + + :meth:`.AbstractConversation.ban` for the full interface + specification. + """ if member.direct_jid is None: raise ValueError( "cannot ban members whose direct JID is not " @@ -908,11 +1104,14 @@ def muc_set_affiliation(self, jid, affiliation, *, reason=None): @asyncio.coroutine def set_topic(self, new_topic): """ - Request to set the subject to `subject`. `subject` must be a mapping - which maps :class:`~.structs.LanguageTag` tags to strings; :data:`None` - is a valid key. + Change the (possibly publicly) visible topic of the conversation. - Return the :class:`~.stream.StanzaToken` obtained from the stream. + :param new_topic: The new topic for the conversation. + :type new_topic: :class:`str` + + Request to set the subject to `new_topic`. `new_topic` must be a + mapping which maps :class:`~.structs.LanguageTag` tags to strings; + :data:`None` is a valid key. """ msg = aioxmpp.stanza.Message( @@ -926,8 +1125,7 @@ def set_topic(self, new_topic): @asyncio.coroutine def leave(self): """ - Request to leave the MUC and wait for it. This effectively calls - :meth:`leave` and waits for the next :meth:`on_exit` event. + Leave the MUC. """ fut = self.on_exit.future() @@ -995,6 +1193,11 @@ def _connect_to_signal(signal, func): class MUCClient(aioxmpp.service.Service): """ + :term:`Conversation Implementation` for Multi-User Chats (:xep:`45`). + + This service provides access to Multi-User Chats using the + conversation interface defined by :mod:`aioxmpp.im`. + Client service implementing the a Multi-User Chat client. By loading it into a client, it is possible to join multi-user chats and implement interaction with them. @@ -1015,6 +1218,11 @@ class MUCClient(aioxmpp.service.Service): is still available under that name, but the alias will be removed in 1.0. + .. versionchanged:: 0.9 + + This class was completely remodeled in 0.9 to conform with the + :class:`aioxmpp.im` interface. + """ ORDER_AFTER = [ @@ -1210,23 +1418,45 @@ def _shutdown(self): def join(self, mucjid, nick, *, password=None, history=None, autorejoin=True): """ + Join a multi-user chat and create a conversation for it. + + :param mucjid: The bare JID of the room to join. + :type mucjid: :class:`~aioxmpp.JID`. + :param nick: The nickname to use in the room. + :type nick: :class:`str` + :param password: The password to join the room, if required. + :type password: :class:`str` + :param history: Specification for how much and which history to fetch. + :type history: :class:`.xso.History` + :param autorejoin: Flag to indicate that the MUC should be + automatically rejoined after a disconnect. + :type autorejoin: :class:`bool` + :raises ValueError: if the MUC JID is invalid. + :return: The :term:`Conversation` and a future on the join. + :rtype: tuple of :class:`~.Room` and :class:`asyncio.Future`. + Join a multi-user chat at `mucjid` with `nick`. Return a :class:`Room` instance which is used to track the MUC locally and a :class:`aioxmpp.Future` which becomes done when the join succeeded (with a :data:`None` value) or failed (with an exception). + In addition, the :meth:`~.ConversationService.on_conversation_added` + signal is emitted immediately with the new :class:`Room`. + It is recommended to attach the desired signals to the :class:`Room` - before yielding next, to avoid races with the server. It is guaranteed - that no signals are emitted before the next yield, and thus, it is safe - to attach the signals right after :meth:`join` returned. (This is also - the reason why :meth:`join` is not a coroutine, but instead returns the - room and a future to wait for.) + before yielding next (e.g. in a non-deferred event handler to the :meth:`~.ConversationService.on_conversation_added` signal), to avoid + races with the server. It is guaranteed that no signals are emitted + before the next yield, and thus, it is safe to attach the signals right + after :meth:`join` returned. (This is also the reason why :meth:`join` + is not a coroutine, but instead returns the room and a future to wait + for.) Any other interaction with the room must go through the :class:`Room` instance. If the multi-user chat at `mucjid` is already or currently being - joined, :class:`ValueError` is raised. + joined, the existing :class:`Room` and future is returned. The `nick` + and other options for the new join are ignored. If the `mucjid` is not a bare JID, :class:`ValueError` is raised. @@ -1242,11 +1472,6 @@ def join(self, mucjid, nick, *, request history since the stream destruction and ignore the `history` object passed here. - .. todo: - - Use the timestamp of the last received message instead of the - timestamp of stream destruction. - If the stream is currently not established, the join is deferred until the stream is established. """ @@ -1258,8 +1483,21 @@ def join(self, mucjid, nick, *, if not mucjid.is_bare: raise ValueError("MUC JID must be bare") - if mucjid in self._pending_mucs: - raise ValueError("already joined") + try: + room, fut, *_ = self._pending_mucs[mucjid] + except KeyError: + pass + else: + return room, fut + + try: + room = self._joined_mucs[mucjid] + except KeyError: + pass + else: + fut = asyncio.Future() + fut.set_result(None) + return room, fut room = Room(self, mucjid) room.muc_autorejoin = autorejoin @@ -1293,12 +1531,24 @@ def join(self, mucjid, nick, *, @asyncio.coroutine def set_affiliation(self, mucjid, jid, affiliation, *, reason=None): """ + Change the affiliation of an entity with a MUC. + + :param mucjid: The bare JID identifying the MUC. + :type mucjid: :class:`~aioxmpp.JID` + :param jid: The bare JID of the entity whose affiliation shall be + changed. + :type jid: :class:`~aioxmpp.JID` + :param affiliation: The new affiliation for the entity. + :type affiliation: :class:`str` + :param reason: Optional reason for the affiliation change. + :type reason: :class:`str` or :data:`None` + Change the affiliation of the given `jid` with the MUC identified by the bare `mucjid` to the given new `affiliation`. Optionally, a `reason` can be given. - If you are joined in the MUC, :meth:`Room.set_affiliation` may be more - convenient, but it is possible to modify the affiliations of a MUC + If you are joined in the MUC, :meth:`Room.muc_set_affiliation` may be + more convenient, but it is possible to modify the affiliations of a MUC without being joined, given sufficient privilegues. Setting the different affiliations require different privilegues of the diff --git a/tests/muc/test_service.py b/tests/muc/test_service.py index 5ff70015..3ad91175 100644 --- a/tests/muc/test_service.py +++ b/tests/muc/test_service.py @@ -1956,7 +1956,7 @@ def test_set_topic(self): ) self.assertFalse(stanza.body) - def test_leave_and_wait(self): + def test_leave(self): fut = asyncio.async(self.jmuc.leave()) run_coroutine(asyncio.sleep(0)) self.assertFalse(fut.done(), fut.done() and fut.result()) @@ -2908,14 +2908,11 @@ def test_join_rejects_incorrect_history_object(self): self.s.get_muc(TEST_MUC_JID) def test_join_rejects_joining_a_pending_muc(self): - self.s.join(TEST_MUC_JID, "firstwitch") - with self.assertRaisesRegex( - ValueError, - "already joined"): - self.s.join( - TEST_MUC_JID, - "thirdwitch", - ) + room, fut = self.s.join(TEST_MUC_JID, "firstwitch") + room2, fut2 = self.s.join(TEST_MUC_JID, "thirdwitch") + + self.assertIs(room, room2) + self.assertIs(fut, fut2) def test_join_rejects_non_bare_muc_jid(self): with self.assertRaisesRegex( @@ -3000,6 +2997,34 @@ def test_join_completed_on_self_presence(self): room ) + def test_join_returns_existing_muc_and_done_future_if_joined(self): + room, future = self.s.join(TEST_MUC_JID, "thirdwitch") + + occupant_presence = aioxmpp.stanza.Presence( + from_=TEST_MUC_JID.replace(resource="thirdwitch"), + ) + occupant_presence.xep0045_muc_user = muc_xso.UserExt( + status_codes={110}, + ) + + self.s._handle_presence( + occupant_presence, + occupant_presence.from_, + False, + ) + + self.assertTrue(future.done()) + self.assertIsNone(future.result()) + + self.assertIs( + self.s.get_muc(TEST_MUC_JID), + room + ) + + room2, future2 = self.s.join(TEST_MUC_JID, "thirdwitch") + self.assertIs(room, room2) + self.assertTrue(future2.done()) + def test_join_not_completed_on_occupant_presence(self): room, future = self.s.join(TEST_MUC_JID, "thirdwitch") From d7d14f394c6286f017435487849d117b278e498b Mon Sep 17 00:00:00 2001 From: Jonas Wielicki Date: Sat, 8 Apr 2017 12:53:08 +0200 Subject: [PATCH 32/40] im-muc: Implement and test MUC PM handling --- aioxmpp/im/p2p.py | 6 +++- aioxmpp/muc/service.py | 14 ++++++-- tests/im/test_p2p.py | 48 +++++++++++++++++++++++++ tests/muc/test_e2e.py | 53 +++++++++++++++++++++++++++- tests/muc/test_service.py | 74 ++++++++++++++++++++++++++++++++++++++- 5 files changed, 189 insertions(+), 6 deletions(-) diff --git a/aioxmpp/im/p2p.py b/aioxmpp/im/p2p.py index 964589f3..c3d155c8 100644 --- a/aioxmpp/im/p2p.py +++ b/aioxmpp/im/p2p.py @@ -146,8 +146,12 @@ def _filter_message(self, msg, peer, sent, source): if ((msg.type_ == aioxmpp.MessageType.CHAT or msg.type_ == aioxmpp.MessageType.NORMAL) and msg.body): + if existing is None: - existing = self._make_conversation(peer.bare()) + conversation_jid = peer.bare() + if msg.xep0045_muc_user is not None: + conversation_jid = peer + existing = self._make_conversation(conversation_jid) existing._handle_message(msg, peer, sent, source) return None diff --git a/aioxmpp/muc/service.py b/aioxmpp/muc/service.py index 5adc73dc..d97bb115 100644 --- a/aioxmpp/muc/service.py +++ b/aioxmpp/muc/service.py @@ -1202,6 +1202,9 @@ class MUCClient(aioxmpp.service.Service): into a client, it is possible to join multi-user chats and implement interaction with them. + Private Messages into the MUC are not handled by this service. They are + handled by the normal :class:`.p2p.Service`. + .. automethod:: join Manage rooms: @@ -1377,15 +1380,20 @@ def _handle_message(self, message, peer, sent, source): and message.xep0045_muc_user): return None - if message.type_ != aioxmpp.MessageType.GROUPCHAT: - return message - mucjid = peer.bare() try: muc = self._joined_mucs[mucjid] except KeyError: return message + if message.type_ != aioxmpp.MessageType.GROUPCHAT: + if muc is not None: + if source == aioxmpp.im.dispatcher.MessageSource.CARBONS: + return None + # tag so that p2p.Service knows what to do + message.xep0045_muc_user = muc_xso.UserExt() + return message + muc._handle_message( message, peer, sent, source ) diff --git a/tests/im/test_p2p.py b/tests/im/test_p2p.py index b14da982..4f8b83a9 100644 --- a/tests/im/test_p2p.py +++ b/tests/im/test_p2p.py @@ -25,6 +25,7 @@ import aioxmpp import aioxmpp.service +import aioxmpp.muc.xso as muc_xso import aioxmpp.im.p2p as p2p import aioxmpp.im.service as im_service @@ -389,6 +390,53 @@ def test_autocreate_based_on_peer(self): im_dispatcher.MessageSource.STREAM, ) + def test_autocreate_with_fulljid_if_muc_tagged(self): + msg = aioxmpp.Message( + type_=aioxmpp.MessageType.CHAT, + from_=PEER_JID.replace(resource="foo"), + ) + msg.body[None] = "foo" + msg.xep0045_muc_user = muc_xso.UserExt() + + with contextlib.ExitStack() as stack: + Conversation = stack.enter_context(unittest.mock.patch( + "aioxmpp.im.p2p.Conversation", + )) + + self.assertIsNone(self.s._filter_message( + msg, + PEER_JID.replace(localpart="fnord", resource="foo"), + False, + im_dispatcher.MessageSource.STREAM, + )) + Conversation.assert_called_once_with( + self.s, + PEER_JID.replace(localpart="fnord", resource="foo"), + parent=None + ) + + c = run_coroutine(self.s.get_conversation( + PEER_JID.replace(localpart="fnord", resource="foo") + )) + Conversation.assert_called_once_with( + self.s, + PEER_JID.replace(localpart="fnord", resource="foo"), + parent=None + ) + + self.assertEqual(c, Conversation()) + + self.listener.on_conversation_new.assert_called_once_with( + Conversation() + ) + + Conversation()._handle_message.assert_called_once_with( + msg, + PEER_JID.replace(localpart="fnord", resource="foo"), + False, + im_dispatcher.MessageSource.STREAM, + ) + def test_autocreate_conversation_from_recvd_normal_with_body(self): msg = aioxmpp.Message( type_=aioxmpp.MessageType.NORMAL, diff --git a/tests/muc/test_e2e.py b/tests/muc/test_e2e.py index 965428d5..67a6f868 100644 --- a/tests/muc/test_e2e.py +++ b/tests/muc/test_e2e.py @@ -24,6 +24,8 @@ import logging import aioxmpp.muc +import aioxmpp.im.p2p +import aioxmpp.im.service from aioxmpp.utils import namespaces @@ -42,7 +44,11 @@ class TestMuc(TestCase): @blocking @asyncio.coroutine def setUp(self, muc_provider): - services = [aioxmpp.MUCClient] + services = [ + aioxmpp.MUCClient, + aioxmpp.im.p2p.Service, + aioxmpp.im.service.ConversationService, + ] self.peer = muc_provider self.mucjid = self.peer.replace(localpart="coven") @@ -441,6 +447,51 @@ def onmessage(message, member, source, **kwargs): if member.nick == "firstwitch"], ) + @blocking_timed + @asyncio.coroutine + def test_muc_pms(self): + firstwitch_convs = self.firstwitch.summon( + aioxmpp.im.service.ConversationService + ) + + firstconv_future = asyncio.Future() + first_msgs = asyncio.Queue() + + def conv_added(conversation): + firstconv_future.set_result(conversation) + + def message(message, member, source, **kwargs): + first_msgs.put_nowait((message, member, source)) + + conversation.on_message.connect(message) + return True + + firstwitch_convs.on_conversation_added.connect(conv_added) + + secondwitch_p2p = self.secondwitch.summon( + aioxmpp.im.p2p.Service, + ) + secondconv = yield from secondwitch_p2p.get_conversation( + self.secondroom.members[1].conversation_jid + ) + + msg = aioxmpp.Message(type_=aioxmpp.MessageType.CHAT) + msg.body[None] = "I'll give thee a wind." + + yield from secondconv.send_message(msg) + firstconv = yield from firstconv_future + + self.assertEqual(firstconv.members[1].conversation_jid, + self.firstroom.members[1].conversation_jid) + + message, member, *_ = yield from first_msgs.get() + self.assertIsInstance(message, aioxmpp.Message) + self.assertDictEqual( + message.body, + msg.body, + ) + self.assertEqual(member, firstconv.members[1]) + @blocking_timed @asyncio.coroutine def test_set_nick(self): diff --git a/tests/muc/test_service.py b/tests/muc/test_service.py index 3ad91175..008a9783 100644 --- a/tests/muc/test_service.py +++ b/tests/muc/test_service.py @@ -2618,6 +2618,23 @@ def test_handle_message_ignores_nonmuc_ccd_message(self): im_dispatcher.MessageSource.CARBONS, ) ) + self.assertIsNone(msg.xep0045_muc_user) + + def test_handle_message_ignores_nonmuc_chat_message(self): + msg = aioxmpp.Message( + type_=aioxmpp.MessageType.CHAT, + from_=TEST_MUC_JID.replace(resource="firstwitch"), + ) + self.assertIs( + msg, + self.s._handle_message( + msg, + msg.from_, + False, + im_dispatcher.MessageSource.STREAM, + ) + ) + self.assertIsNone(msg.xep0045_muc_user) def test_handle_message_drops_received_carbon_of_pm(self): msg = aioxmpp.Message( @@ -3146,7 +3163,7 @@ def mkpresence(nick, is_self=False): unittest.mock.sentinel.source, ) - def test_ignores_chat_messages_from_joined_mucs(self): + def test_tags_chat_messages_from_joined_mucs(self): room, future = self.s.join(TEST_MUC_JID, "thirdwitch") def mkpresence(nick, is_self=False): @@ -3195,6 +3212,61 @@ def mkpresence(nick, is_self=False): ) ) + self.assertIsInstance( + msg.xep0045_muc_user, + muc_xso.UserExt, + ) + + _handle_message.assert_not_called() + + def test_drop_untagged_pm_carbons_from_joined_mucs(self): + room, future = self.s.join(TEST_MUC_JID, "thirdwitch") + + def mkpresence(nick, is_self=False): + presence = aioxmpp.stanza.Presence( + from_=TEST_MUC_JID.replace(resource=nick) + ) + presence.xep0045_muc_user = muc_xso.UserExt( + status_codes={110} if is_self else set() + ) + return presence + + occupant_presences = [ + mkpresence(nick, is_self=(nick == "thirdwitch")) + for nick in [ + "firstwitch", + "secondwitch", + "thirdwitch", + ] + ] + + msg = aioxmpp.stanza.Message( + from_=TEST_MUC_JID.replace(resource="firstwitch"), + type_=aioxmpp.structs.MessageType.CHAT, + ) + + with contextlib.ExitStack() as stack: + _handle_message = stack.enter_context(unittest.mock.patch.object( + room, + "_handle_message", + )) + + for presence in occupant_presences: + self.s._handle_presence( + presence, + presence.from_, + False, + ) + + self.assertIsNone( + self.s._handle_message( + msg, + msg.from_, + unittest.mock.sentinel.sent, + im_dispatcher.MessageSource.CARBONS, + ) + ) + _handle_message.assert_not_called() def test_muc_is_untracked_when_user_leaves(self): From 1ff978fda455c6ccaf137fa532b35bfbac49cc8a Mon Sep 17 00:00:00 2001 From: Jonas Wielicki Date: Sat, 8 Apr 2017 13:00:01 +0200 Subject: [PATCH 33/40] im-muc: Provide nickname of entity who set topic in on_topic_changed event --- aioxmpp/muc/service.py | 10 +++++++++- tests/muc/test_service.py | 4 +++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/aioxmpp/muc/service.py b/aioxmpp/muc/service.py index d97bb115..d4195804 100644 --- a/aioxmpp/muc/service.py +++ b/aioxmpp/muc/service.py @@ -356,7 +356,7 @@ class Room(aioxmpp.im.conversation.AbstractConversation): :meth:`.AbstractConversation.on_nick_changed` for the full specification. - .. signal:: on_topic_changed(member, new_topic, **kwargs) + .. signal:: on_topic_changed(member, new_topic, *, muc_nick=None, **kwargs) The topic of the conversation has changed. @@ -364,11 +364,18 @@ class Room(aioxmpp.im.conversation.AbstractConversation): :type member: :class:`Occupant` or :data:`None` :param new_topic: The new topic of the conversation. :type new_topic: :class:`.LanguageMap` + :param muc_nick: The nickname of the occupant who changed the topic. + :type muc_nick: :class:`str` The `member` is matched by nickname. It is possible that the member is not in the room at the time the topic chagne is received (for example on a join). + `muc_nick` is always the nickname of the entity who changed the topic. + If the entity is currently not joined or has changed nick since the + topic was set, `member` will be :data:`None`, but `muc_nick` is still + the nickname of the actor. + .. note:: :meth:`on_topic_changed` is emitted during join, iff a topic is set @@ -658,6 +665,7 @@ def _handle_message(self, message, peer, sent, source): self.on_topic_changed( occupant, self._subject, + muc_nick=message.from_.resource, ) elif message.body: diff --git a/tests/muc/test_service.py b/tests/muc/test_service.py index 008a9783..29e7c7f3 100644 --- a/tests/muc/test_service.py +++ b/tests/muc/test_service.py @@ -1379,7 +1379,8 @@ def test_handle_message_handles_subject_of_occupant(self): occupant, { None: "foo", - } + }, + muc_nick=occupant.nick, ) ] ) @@ -1416,6 +1417,7 @@ def test_handle_message_handles_subject_of_non_occupant(self): unittest.mock.call.on_topic_changed( None, msg.subject, + muc_nick=msg.from_.resource, ) ] ) From bb79fa5559d534402e0fa023df9de0dc62a44832 Mon Sep 17 00:00:00 2001 From: Jonas Wielicki Date: Sat, 8 Apr 2017 13:00:18 +0200 Subject: [PATCH 34/40] im-muc: Update muc_logger.py to work with new MUCClient --- examples/muc_logger.py | 35 +++++++++++++++++------------------ 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/examples/muc_logger.py b/examples/muc_logger.py index 2ecbe8a7..0cae7403 100644 --- a/examples/muc_logger.py +++ b/examples/muc_logger.py @@ -109,7 +109,7 @@ def make_simple_client(self): ) room.on_message.connect(self._on_message) - room.on_subject_change.connect(self._on_subject_change) + room.on_topic_changed.connect(self._on_topic_changed) room.on_enter.connect(self._on_enter) room.on_exit.connect(self._on_exit) room.on_leave.connect(self._on_leave) @@ -117,45 +117,44 @@ def make_simple_client(self): return client - def _on_message(self, message, **kwargs): + def _on_message(self, message, member, source, **kwargs): print("{} {}: {}".format( datetime.utcnow().isoformat(), - message.from_.resource, + member.nick, message.body.lookup(self.language_selectors), )) - def _on_subject_change(self, message, subject, **kwargs): + def _on_topic_changed(self, member, new_topic, *, muc_nick=None, **kwargs): print("{} *** topic set by {}: {}".format( datetime.utcnow().isoformat(), - message.from_.resource, - subject.lookup(self.language_selectors), + member.nick if member is not None else muc_nick, + new_topic.lookup(self.language_selectors), )) - def _on_enter(self, presence, occupant=None, **kwargs): + def _on_enter(self, presence, occupant, **kwargs): print("{} *** entered room {}".format( datetime.utcnow().isoformat(), - presence.from_.bare() + presence.from_.bare(), )) - def _on_exit(self, presence, occupant=None, **kwargs): - print("{} *** left room {}".format( + def _on_exit(self, **kwargs): + print("{} *** left room".format( datetime.utcnow().isoformat(), - presence.from_.bare() )) - def _on_join(self, presence, occupant=None, **kwargs): + def _on_join(self, member, **kwargs): print("{} *** {} [{}] entered room".format( datetime.utcnow().isoformat(), - occupant.nick, - occupant.jid, + member.nick, + member.direct_jid, )) - def _on_leave(self, presence, occupant, mode, **kwargs): + def _on_leave(self, member, muc_leave_mode=None, **kwargs): print("{} *** {} [{}] left room ({})".format( datetime.utcnow().isoformat(), - occupant.nick, - occupant.jid, - mode + member.nick, + member.direct_jid, + muc_leave_mode, )) @asyncio.coroutine From 648d9acaea0a277c1588bc56b6db908cc7602f73 Mon Sep 17 00:00:00 2001 From: Jonas Wielicki Date: Sat, 8 Apr 2017 13:02:30 +0200 Subject: [PATCH 35/40] im: changelog entries --- docs/api/changelog.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/api/changelog.rst b/docs/api/changelog.rst index ca53ec4a..eb0437d2 100644 --- a/docs/api/changelog.rst +++ b/docs/api/changelog.rst @@ -99,6 +99,10 @@ Version 0.9 API is more clearly defined and more correct. The (ab-)use of :class:`aioxmpp.statemachine.OrderedStateMachine` never really worked anyways. +* :mod:`aioxmpp.im`. + +* **Breaking change:** Re-design of interface to :mod:`aioxmpp.muc`. + .. _api-changelog-0.8: Version 0.8 From cfa7cdd660a2b1c5ea59390540c53458d294e4a2 Mon Sep 17 00:00:00 2001 From: Jonas Wielicki Date: Sat, 8 Apr 2017 14:22:01 +0200 Subject: [PATCH 36/40] im-muc: Add inline comment explaining the rationale for empty on messages --- aioxmpp/muc/service.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/aioxmpp/muc/service.py b/aioxmpp/muc/service.py index d4195804..5c16b4ca 100644 --- a/aioxmpp/muc/service.py +++ b/aioxmpp/muc/service.py @@ -869,6 +869,10 @@ def send_message(self, msg): """ msg.type_ = aioxmpp.MessageType.GROUPCHAT msg.to = self._mucjid + # see https://mail.jabber.org/pipermail/standards/2017-January/032048.html # NOQA + # for a full discussion on the rationale for this. + # TL;DR: we want to help entities to discover that a message is related + # to a MUC. msg.xep0045_muc_user = muc_xso.UserExt() yield from self.service.client.stream.send(msg) self.on_message( @@ -937,11 +941,15 @@ def send_message_tracked(self, msg): the correct body a reflection. Obviously, this fails consistently in MUCs which re-write the body and - re-write the ID and randomly if the MUC always re-writes the ID but only - sometimes the body. + re-write the ID and randomly if the MUC always re-writes the ID but + only sometimes the body. """ msg.type_ = aioxmpp.MessageType.GROUPCHAT msg.to = self._mucjid + # see https://mail.jabber.org/pipermail/standards/2017-January/032048.html # NOQA + # for a full discussion on the rationale for this. + # TL;DR: we want to help entities to discover that a message is related + # to a MUC. msg.xep0045_muc_user = muc_xso.UserExt() msg.autoset_id() tracking_svc = self.service.dependencies[ From 1427408c69e10e566d292f9ed2c0e4fd8419ba71 Mon Sep 17 00:00:00 2001 From: Jonas Wielicki Date: Sat, 8 Apr 2017 14:22:43 +0200 Subject: [PATCH 37/40] im-muc: Fix docs typo --- aioxmpp/muc/service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aioxmpp/muc/service.py b/aioxmpp/muc/service.py index 5c16b4ca..627e9acc 100644 --- a/aioxmpp/muc/service.py +++ b/aioxmpp/muc/service.py @@ -581,7 +581,7 @@ def members(self): def features(self): """ The set of features supported by this MUC. This may vary depending on - features expotred by the MUC service, so be sure to check this for each + features exported by the MUC service, so be sure to check this for each individual MUC. """ From af1a278f07bc6ce430d0dc150bef05a138245115 Mon Sep 17 00:00:00 2001 From: Jonas Wielicki Date: Sat, 8 Apr 2017 15:12:32 +0200 Subject: [PATCH 38/40] im-muc: Implement tracking for consecutive messages with identical text --- aioxmpp/muc/service.py | 40 +++++- tests/muc/test_service.py | 280 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 314 insertions(+), 6 deletions(-) diff --git a/aioxmpp/muc/service.py b/aioxmpp/muc/service.py index 627e9acc..07ec7378 100644 --- a/aioxmpp/muc/service.py +++ b/aioxmpp/muc/service.py @@ -39,6 +39,21 @@ from . import xso as muc_xso +def _extract_one_pair(body): + """ + Extract one language-text pair from a :class:`~.LanguageMap`. + + This is used for tracking. + """ + if not body: + return None, None + + try: + return None, body[None] + except KeyError: + return next(iter(body.items())) + + class LeaveMode(Enum): """ The different reasons for a user to leave or be removed from MUC. @@ -618,18 +633,28 @@ def _match_tracker(self, message): try: tracker = self._tracking_by_id[message.id_] except KeyError: + key = message.from_, _extract_one_pair(message.body) try: - tracker = self._tracking_by_sender_body[ - message.from_, message.body.get(None) - ] + trackers = self._tracking_by_sender_body[key] except KeyError: + trackers = None + + if not trackers: tracker = None + else: + tracker = trackers[0] + if tracker is None: return False id_key, sender_body_key = self._tracking_metadata.pop(tracker) del self._tracking_by_id[id_key] - del self._tracking_by_sender_body[sender_body_key] + + # remove tracker from list and delete list map entry if empty + trackers = self._tracking_by_sender_body[sender_body_key] + del trackers[0] + if not trackers: + del self._tracking_by_sender_body[sender_body_key] try: tracker._set_state( @@ -958,13 +983,16 @@ def send_message_tracked(self, msg): tracker = aioxmpp.tracking.MessageTracker() id_key = msg.id_ sender_body_key = (self._this_occupant.conversation_jid, - msg.body.get(None)) + _extract_one_pair(msg.body)) self._tracking_by_id[id_key] = tracker self._tracking_metadata[tracker] = ( id_key, sender_body_key, ) - self._tracking_by_sender_body[sender_body_key] = tracker + self._tracking_by_sender_body.setdefault( + sender_body_key, + [] + ).append(tracker) tracker.on_closed.connect(functools.partial( self._tracker_closed, tracker, diff --git a/tests/muc/test_service.py b/tests/muc/test_service.py index 29e7c7f3..8be0ff6b 100644 --- a/tests/muc/test_service.py +++ b/tests/muc/test_service.py @@ -2354,6 +2354,286 @@ def test_tracker_matches_on_body_and_from_too(self): self.base.on_message.assert_not_called() + def test_tracker_does_not_match_for_different_from(self): + presence = aioxmpp.stanza.Presence( + type_=aioxmpp.structs.PresenceType.AVAILABLE, + from_=TEST_MUC_JID.replace(resource="thirdwitch") + ) + presence.xep0045_muc_user = muc_xso.UserExt( + items=[ + muc_xso.UserItem(affiliation="member", + role="participant"), + ], + status_codes={110}, + ) + + self.jmuc._inbound_muc_user_presence(presence) + + msg = aioxmpp.Message(aioxmpp.MessageType.NORMAL) + msg.body.update({None: "some text"}) + + tracker = run_coroutine( + self.jmuc.send_message_tracked(msg) + ) + + self.assertEqual( + tracker.state, + aioxmpp.tracking.MessageState.IN_TRANSIT, + ) + + reflected = aioxmpp.Message( + type_=aioxmpp.MessageType.GROUPCHAT, + from_=self.jmuc.me.conversation_jid.replace(resource="fnord"), + id_="#notmyid", + ) + reflected.body[None] = "some text" + + self.jmuc._handle_message( + reflected, + reflected.from_, + False, + im_dispatcher.MessageSource.STREAM, + ) + + self.assertEqual( + tracker.state, + aioxmpp.tracking.MessageState.IN_TRANSIT, + ) + + self.assertIsNone(tracker.response) + + self.base.on_message.assert_called_once_with( + reflected, + None, + im_dispatcher.MessageSource.STREAM + ) + + def test_tracker_does_not_match_for_different_body(self): + presence = aioxmpp.stanza.Presence( + type_=aioxmpp.structs.PresenceType.AVAILABLE, + from_=TEST_MUC_JID.replace(resource="thirdwitch") + ) + presence.xep0045_muc_user = muc_xso.UserExt( + items=[ + muc_xso.UserItem(affiliation="member", + role="participant"), + ], + status_codes={110}, + ) + + self.jmuc._inbound_muc_user_presence(presence) + + msg = aioxmpp.Message(aioxmpp.MessageType.NORMAL) + msg.body.update({None: "some text"}) + + tracker = run_coroutine( + self.jmuc.send_message_tracked(msg) + ) + + self.assertEqual( + tracker.state, + aioxmpp.tracking.MessageState.IN_TRANSIT, + ) + + reflected = aioxmpp.Message( + type_=aioxmpp.MessageType.GROUPCHAT, + from_=self.jmuc.me.conversation_jid, + id_="#notmyid", + ) + reflected.body[None] = "some other text" + + self.jmuc._handle_message( + reflected, + reflected.from_, + False, + im_dispatcher.MessageSource.STREAM, + ) + + self.assertEqual( + tracker.state, + aioxmpp.tracking.MessageState.IN_TRANSIT, + ) + + self.assertIsNone(tracker.response) + + self.base.on_message.assert_called_once_with( + reflected, + self.jmuc.me, + im_dispatcher.MessageSource.STREAM + ) + + def test_tracker_can_deal_with_localised_messages(self): + presence = aioxmpp.stanza.Presence( + type_=aioxmpp.structs.PresenceType.AVAILABLE, + from_=TEST_MUC_JID.replace(resource="thirdwitch") + ) + presence.xep0045_muc_user = muc_xso.UserExt( + items=[ + muc_xso.UserItem(affiliation="member", + role="participant"), + ], + status_codes={110}, + ) + + self.jmuc._inbound_muc_user_presence(presence) + + msg = aioxmpp.Message(aioxmpp.MessageType.NORMAL) + msg.body.update( + {aioxmpp.structs.LanguageTag.fromstr("de"): "ein Text"} + ) + + tracker = run_coroutine( + self.jmuc.send_message_tracked(msg) + ) + + self.assertEqual( + tracker.state, + aioxmpp.tracking.MessageState.IN_TRANSIT, + ) + + other_message = aioxmpp.Message( + type_=aioxmpp.MessageType.GROUPCHAT, + from_=self.jmuc.me.conversation_jid, + id_="#notmyid", + ) + other_message.body[aioxmpp.structs.LanguageTag.fromstr("de")] = "ein anderer Text" + + self.jmuc._handle_message( + other_message, + other_message.from_, + False, + im_dispatcher.MessageSource.STREAM, + ) + + self.assertEqual( + tracker.state, + aioxmpp.tracking.MessageState.IN_TRANSIT, + ) + + self.assertIsNone(tracker.response) + + self.base.on_message.assert_called_once_with( + other_message, + self.jmuc.me, + im_dispatcher.MessageSource.STREAM + ) + self.base.on_message.reset_mock() + self.base.on_message.return_value = None + + reflected = aioxmpp.Message( + type_=aioxmpp.MessageType.GROUPCHAT, + from_=self.jmuc.me.conversation_jid, + id_="#notmyid", + ) + reflected.body[aioxmpp.structs.LanguageTag.fromstr("de")] = "ein Text" + + self.jmuc._handle_message( + reflected, + reflected.from_, + False, + im_dispatcher.MessageSource.STREAM, + ) + + self.assertEqual( + tracker.state, + aioxmpp.tracking.MessageState.DELIVERED_TO_RECIPIENT, + ) + + self.assertIs(tracker.response, reflected) + + self.base.on_message.assert_not_called() + + def test_tracker_body_from_match_works_with_multiple_identical_messages(self): + presence = aioxmpp.stanza.Presence( + type_=aioxmpp.structs.PresenceType.AVAILABLE, + from_=TEST_MUC_JID.replace(resource="thirdwitch") + ) + presence.xep0045_muc_user = muc_xso.UserExt( + items=[ + muc_xso.UserItem(affiliation="member", + role="participant"), + ], + status_codes={110}, + ) + + self.jmuc._inbound_muc_user_presence(presence) + + msg1 = aioxmpp.Message(aioxmpp.MessageType.NORMAL) + msg1.body.update({None: "some text"}) + + msg2 = aioxmpp.Message(aioxmpp.MessageType.NORMAL) + msg2.body.update({None: "some text"}) + + tracker1 = run_coroutine( + self.jmuc.send_message_tracked(msg1) + ) + + tracker2 = run_coroutine( + self.jmuc.send_message_tracked(msg2) + ) + + self.assertEqual( + tracker1.state, + aioxmpp.tracking.MessageState.IN_TRANSIT, + ) + + self.assertEqual( + tracker2.state, + aioxmpp.tracking.MessageState.IN_TRANSIT, + ) + + reflected1 = aioxmpp.Message( + type_=aioxmpp.MessageType.GROUPCHAT, + from_=self.jmuc.me.conversation_jid, + id_="#notmyid", + ) + reflected1.body[None] = "some text" + + self.jmuc._handle_message( + reflected1, + reflected1.from_, + False, + im_dispatcher.MessageSource.STREAM, + ) + + self.assertEqual( + tracker1.state, + aioxmpp.tracking.MessageState.DELIVERED_TO_RECIPIENT, + ) + + self.assertIs(tracker1.response, reflected1) + + self.assertEqual( + tracker2.state, + aioxmpp.tracking.MessageState.IN_TRANSIT, + ) + + reflected2 = aioxmpp.Message( + type_=aioxmpp.MessageType.GROUPCHAT, + from_=self.jmuc.me.conversation_jid, + id_="#notmyid", + ) + reflected2.body[None] = "some text" + + self.jmuc._handle_message( + reflected2, + reflected2.from_, + False, + im_dispatcher.MessageSource.STREAM, + ) + + self.assertEqual( + tracker2.state, + aioxmpp.tracking.MessageState.DELIVERED_TO_RECIPIENT, + ) + + self.assertIs( + tracker2.response, + reflected2, + ) + + self.base.on_message.assert_not_called() + def test_tracking_does_not_fail_on_race(self): presence = aioxmpp.stanza.Presence( type_=aioxmpp.structs.PresenceType.AVAILABLE, From bbbea53d7b3e50301c40459c12510859af8a7c2f Mon Sep 17 00:00:00 2001 From: Jonas Wielicki Date: Tue, 11 Apr 2017 14:12:25 +0200 Subject: [PATCH 39/40] im-muc: Re-work body tracking to work on stanzas from our current jid only --- aioxmpp/muc/service.py | 33 +++++++++++---------- tests/muc/test_service.py | 62 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 15 deletions(-) diff --git a/aioxmpp/muc/service.py b/aioxmpp/muc/service.py index 07ec7378..0eae9387 100644 --- a/aioxmpp/muc/service.py +++ b/aioxmpp/muc/service.py @@ -512,7 +512,7 @@ def __init__(self, service, mucjid): self._this_occupant = None self._tracking_by_id = {} self._tracking_metadata = {} - self._tracking_by_sender_body = {} + self._tracking_by_body = {} self.muc_autorejoin = False self.muc_password = None @@ -633,10 +633,14 @@ def _match_tracker(self, message): try: tracker = self._tracking_by_id[message.id_] except KeyError: - key = message.from_, _extract_one_pair(message.body) - try: - trackers = self._tracking_by_sender_body[key] - except KeyError: + if (self._this_occupant is not None and + message.from_ == self._this_occupant.conversation_jid): + key = _extract_one_pair(message.body) + try: + trackers = self._tracking_by_body[key] + except KeyError: + trackers = None + else: trackers = None if not trackers: @@ -647,14 +651,14 @@ def _match_tracker(self, message): if tracker is None: return False - id_key, sender_body_key = self._tracking_metadata.pop(tracker) + id_key, body_key = self._tracking_metadata.pop(tracker) del self._tracking_by_id[id_key] # remove tracker from list and delete list map entry if empty - trackers = self._tracking_by_sender_body[sender_body_key] + trackers = self._tracking_by_body[body_key] del trackers[0] if not trackers: - del self._tracking_by_sender_body[sender_body_key] + del self._tracking_by_body[body_key] try: tracker._set_state( @@ -908,11 +912,11 @@ def send_message(self, msg): def _tracker_closed(self, tracker): try: - id_key, sender_body_key = self._tracking_metadata[tracker] + id_key, body_key = self._tracking_metadata[tracker] except KeyError: return self._tracking_by_id.pop(id_key, None) - self._tracking_by_sender_body.pop(sender_body_key, None) + self._tracking_by_body.pop(body_key, None) @asyncio.coroutine def send_message_tracked(self, msg): @@ -982,15 +986,14 @@ def send_message_tracked(self, msg): ] tracker = aioxmpp.tracking.MessageTracker() id_key = msg.id_ - sender_body_key = (self._this_occupant.conversation_jid, - _extract_one_pair(msg.body)) + body_key = _extract_one_pair(msg.body) self._tracking_by_id[id_key] = tracker self._tracking_metadata[tracker] = ( id_key, - sender_body_key, + body_key, ) - self._tracking_by_sender_body.setdefault( - sender_body_key, + self._tracking_by_body.setdefault( + body_key, [] ).append(tracker) tracker.on_closed.connect(functools.partial( diff --git a/tests/muc/test_service.py b/tests/muc/test_service.py index 8be0ff6b..bb1cf730 100644 --- a/tests/muc/test_service.py +++ b/tests/muc/test_service.py @@ -2462,6 +2462,68 @@ def test_tracker_does_not_match_for_different_body(self): im_dispatcher.MessageSource.STREAM ) + def test_tracker_follows_concurrent_nickchange(self): + presence = aioxmpp.stanza.Presence( + type_=aioxmpp.structs.PresenceType.AVAILABLE, + from_=TEST_MUC_JID.replace(resource="thirdwitch") + ) + presence.xep0045_muc_user = muc_xso.UserExt( + items=[ + muc_xso.UserItem(affiliation="member", + role="participant"), + ], + status_codes={110}, + ) + + self.jmuc._inbound_muc_user_presence(presence) + + msg = aioxmpp.Message(aioxmpp.MessageType.NORMAL) + msg.body.update({None: "some text"}) + + tracker = run_coroutine( + self.jmuc.send_message_tracked(msg) + ) + + self.assertEqual( + tracker.state, + aioxmpp.tracking.MessageState.IN_TRANSIT, + ) + + presence.type_ = aioxmpp.structs.PresenceType.UNAVAILABLE + presence.xep0045_muc_user.status_codes.add(303) + presence.xep0045_muc_user.status_codes.add(110) + presence.xep0045_muc_user.items[0].nick = "oldhag" + + self.jmuc._inbound_muc_user_presence(presence) + + self.assertEqual( + self.jmuc.me.conversation_jid, + TEST_MUC_JID.replace(resource="oldhag") + ) + + reflected = aioxmpp.Message( + type_=aioxmpp.MessageType.GROUPCHAT, + from_=TEST_MUC_JID.replace(resource="oldhag"), + id_="#notmyid", + ) + reflected.body[None] = "some text" + + self.jmuc._handle_message( + reflected, + reflected.from_, + False, + im_dispatcher.MessageSource.STREAM, + ) + + self.assertEqual( + tracker.state, + aioxmpp.tracking.MessageState.DELIVERED_TO_RECIPIENT, + ) + + self.assertIs(tracker.response, reflected) + + self.base.on_message.assert_not_called() + def test_tracker_can_deal_with_localised_messages(self): presence = aioxmpp.stanza.Presence( type_=aioxmpp.structs.PresenceType.AVAILABLE, From ad976dc1a994add36177a84fa69b89d601145e6c Mon Sep 17 00:00:00 2001 From: Jonas Wielicki Date: Tue, 11 Apr 2017 14:14:55 +0200 Subject: [PATCH 40/40] im-muc: Use Language which sorts first in _extract_one_pair --- aioxmpp/muc/service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aioxmpp/muc/service.py b/aioxmpp/muc/service.py index 0eae9387..9dd1cc27 100644 --- a/aioxmpp/muc/service.py +++ b/aioxmpp/muc/service.py @@ -51,7 +51,7 @@ def _extract_one_pair(body): try: return None, body[None] except KeyError: - return next(iter(body.items())) + return min(body.items(), key=lambda x: x[0]) class LeaveMode(Enum):