Repository: caronc/apprise

Issue:
Matrix v3 messages are rejected as duplicates when the login session is reused
:mega: **Notification Service(s) Impacted**
Matrix

:lady_beetle: **Describe the bug**

In Matrix's v3 API [when a client sends a message](https://spec.matrix.org/unstable/client-server-api/#put_matrixclientv3roomsroomidsendeventtypetxnid) the last URL segment is the [Transaction Identifier](https://spec.matrix.org/unstable/client-server-api/#transaction-identifiers).

Summing up the docs _(this is not the official wording!)_:

> The purpose of the transaction ID is to allow the homeserver to distinguish a new request from a retransmission.
> The client should change the transaction ID value for each request (how is not specified; a monotonically increasing integer is recommended).
> The homeserver identifies a request as a retransmission if the transaction ID and HTTP path are the same as a previous request.
> The scope of a transaction ID is for a single device. If a client logs out and back in between two requests using the same transaction ID, they are considered distinct because logging in creates a new device [...]. If a client re-uses a transaction ID for the same endpoint after refreshing an access token, it will be assumed to be a duplicate request and ignored.

Apprise [currently always uses a transaction ID of `0`](https://github.com/caronc/apprise/blob/286a4374c0988eb996c469557617b8b0c3d885d5/apprise/plugins/matrix.py#L615), and thus when the login session is retained (through [`_login()`](https://github.com/caronc/apprise/blob/286a4374c0988eb996c469557617b8b0c3d885d5/apprise/plugins/matrix.py#L810)) only the first message that gets sent is actually accepted by the Matrix homeserver. All the subsequent ones are ignored and the homeserver replies with the internal event ID of the first message, since it believes that these new requests are indeed retransmissions.

Terminating and restarting the Python application that uses Apprise solves the problem because a new login session (and thus a new device, in Matrix terms) is created and the transaction ID of 0 is now valid for a (single) new request. The Apprise CLI client is thus immune from this issue since it (always, I presume?) starts from scratch without an estabilished session.

:bulb: **Screenshots and Logs**
N/A

:computer: **Your System Details:**
 - OS: Debian 11 (Bullseye)
 - Python Version: Python 3.9.2
 - Apprise Version: 1.7.0

:crystal_ball: **Additional context**
+ Synapse version: 1.107.0


Discussion:
caronc: Thank you for this pr!🙏

Patch:
diff --git a/apprise/plugins/matrix.py b/apprise/plugins/matrix.py
index c1401f2..70a4098 100644
--- a/apprise/plugins/matrix.py
+++ b/apprise/plugins/matrix.py
@@ -293,6 +293,9 @@ class NotifyMatrix(NotifyBase):
         # This gets initialized after a login/registration
         self.access_token = None
 
+        # This gets incremented for each request made against the v3 API
+        self.transaction_id = 0
+
         # Place an image inline with the message body
         self.include_image = include_image
 
@@ -612,8 +615,10 @@ class NotifyMatrix(NotifyBase):
 
             # Build our path
             if self.version == MatrixVersion.V3:
-                path = '/rooms/{}/send/m.room.message/0'.format(
-                    NotifyMatrix.quote(room_id))
+                path = '/rooms/{}/send/m.room.message/{}'.format(
+                    NotifyMatrix.quote(room_id),
+                    self.transaction_id,
+                )
 
             else:
                 path = '/rooms/{}/send/m.room.message'.format(
@@ -685,6 +690,12 @@ class NotifyMatrix(NotifyBase):
             method = 'PUT' if self.version == MatrixVersion.V3 else 'POST'
             postokay, response = self._fetch(
                 path, payload=payload, method=method)
+
+            # Increment the transaction ID to avoid future messages being
+            # recognized as retransmissions and ignored
+            if self.version == MatrixVersion.V3:
+                self.transaction_id += 1
+
             if not postokay:
                 # Notify our user
                 self.logger.warning(


Test patch:
diff --git a/test/test_plugin_matrix.py b/test/test_plugin_matrix.py
index df7e4dc..a9916ad 100644
--- a/test/test_plugin_matrix.py
+++ b/test/test_plugin_matrix.py
@@ -1137,3 +1137,58 @@ def test_plugin_matrix_attachments_api_v2(mock_post, mock_get):
 
     # Force __del__() call
     del obj
+
+
+@mock.patch('requests.put')
+@mock.patch('requests.get')
+@mock.patch('requests.post')
+def test_plugin_matrix_transaction_ids_api_v3(mock_post, mock_get, mock_put):
+    """
+    NotifyMatrix() Transaction ID Checks (v3)
+
+    """
+
+    # Prepare a good response
+    response = mock.Mock()
+    response.status_code = requests.codes.ok
+    response.content = MATRIX_GOOD_RESPONSE.encode('utf-8')
+
+    # Prepare a bad response
+    bad_response = mock.Mock()
+    bad_response.status_code = requests.codes.internal_server_error
+
+    # Prepare Mock return object
+    mock_post.return_value = response
+    mock_get.return_value = response
+    mock_put.return_value = response
+
+    notifications_to_send = [10, 1]
+    mock_post_offset = mock_put_counter = 0
+
+    for logins, notifications in enumerate(notifications_to_send, start=1):
+        # Instantiate our object
+        obj = Apprise.instantiate('matrix://user:pass@localhost/#general?v=3')
+
+        for txnId in range(notifications):
+            assert obj.notify(
+                body='body', title='title', notify_type=NotifyType.INFO
+            ) is True
+
+            # Test our call count
+            assert mock_put.call_count == mock_put_counter + 1
+            # Login & join must happen only once per session
+            assert mock_post.call_count == mock_post_offset + (2 * logins)
+            assert mock_post.call_args_list[0][0][0] == \
+                'http://localhost/_matrix/client/v3/login'
+            assert mock_post.call_args_list[1][0][0] == \
+                'http://localhost/_matrix/client/v3/join/' + \
+                '%23general%3Alocalhost'
+            assert mock_put.call_args_list[txnId][0][0] == \
+                'http://localhost/_matrix/client/v3/rooms/' + \
+                f'%21abc123%3Alocalhost/send/m.room.message/{txnId}'
+
+            mock_put_counter = mock_put.call_count
+
+        # Force a object removal (thus a logout call)
+        del obj
+        mock_post_offset += 1