diff --git a/core/clients/signal.py b/core/clients/signal.py index 06352f6..0a715ff 100644 --- a/core/clients/signal.py +++ b/core/clients/signal.py @@ -1039,6 +1039,23 @@ class SignalClient(ClientBase): ) if result is False or result is None: raise RuntimeError("signal_send_failed") + legacy_message_id = str(metadata.get("legacy_message_id") or "").strip() + if legacy_message_id and isinstance(result, int): + message_row = await sync_to_async( + lambda: Message.objects.filter(id=legacy_message_id).first() + )() + if message_row is not None: + update_fields = [] + if int(message_row.delivered_ts or 0) <= 0: + message_row.delivered_ts = int(result) + update_fields.append("delivered_ts") + if str(message_row.source_message_id or "").strip() != str(result): + message_row.source_message_id = str(result) + update_fields.append("source_message_id") + if update_fields: + await sync_to_async(message_row.save)( + update_fields=update_fields + ) transport.set_runtime_command_result( self.service, command_id, @@ -1275,6 +1292,45 @@ class SignalClient(ClientBase): destination_uuid, destination_number, ) + reaction_payload = _extract_signal_reaction(envelope) + if identifiers and isinstance(reaction_payload, dict): + source_uuid = str( + envelope.get("sourceUuid") or envelope.get("source") or "" + ).strip() + source_number = str(envelope.get("sourceNumber") or "").strip() + for identifier in identifiers: + try: + await history.apply_reaction( + identifier.user, + identifier, + target_message_id="", + target_ts=int(reaction_payload.get("target_ts") or 0), + emoji=str(reaction_payload.get("emoji") or ""), + source_service="signal", + actor=(source_uuid or source_number or ""), + remove=bool(reaction_payload.get("remove")), + payload=reaction_payload.get("raw") or {}, + ) + except Exception as exc: + self.log.warning( + "signal raw sync reaction history apply failed: %s", exc + ) + try: + await self.ur.xmpp.client.apply_external_reaction( + identifier.user, + identifier, + source_service="signal", + emoji=str(reaction_payload.get("emoji") or ""), + remove=bool(reaction_payload.get("remove")), + upstream_message_id="", + upstream_ts=int(reaction_payload.get("target_ts") or 0), + actor=(source_uuid or source_number or ""), + payload=reaction_payload.get("raw") or {}, + ) + except Exception as exc: + self.log.warning( + "signal raw sync reaction relay to XMPP failed: %s", exc + ) if identifiers and text: ts_raw = ( sync_sent_message.get("timestamp") @@ -1358,6 +1414,21 @@ class SignalClient(ClientBase): return identifiers = await self._resolve_signal_identifiers(source_uuid, source_number) + reaction_payload = _extract_signal_reaction(envelope) + if (not identifiers) and isinstance(reaction_payload, dict): + # Sync reactions from our own linked device can arrive with source=our + # account and destination=. Resolve by destination as fallback. + destination_uuid = str( + envelope.get("destinationServiceId") + or envelope.get("destinationUuid") + or "" + ).strip() + destination_number = str(envelope.get("destinationNumber") or "").strip() + if destination_uuid or destination_number: + identifiers = await self._resolve_signal_identifiers( + destination_uuid, + destination_number, + ) if not identifiers: identifiers = await self._auto_link_single_user_signal_identifier( source_uuid, source_number @@ -1371,7 +1442,6 @@ class SignalClient(ClientBase): ) return - reaction_payload = _extract_signal_reaction(envelope) if isinstance(reaction_payload, dict): for identifier in identifiers: try: diff --git a/core/tests/test_signal_reply_send.py b/core/tests/test_signal_reply_send.py index 8851be0..3bee168 100644 --- a/core/tests/test_signal_reply_send.py +++ b/core/tests/test_signal_reply_send.py @@ -221,3 +221,101 @@ class SignalInboundReplyLinkTests(TransactionTestCase): any(str(row.get("emoji") or "") == "❤️" for row in reactions), "Expected Signal heart reaction to be applied to anchor receipt payload.", ) + + def test_process_raw_inbound_event_applies_sync_reaction_using_destination_fallback(self): + fake_ur = Mock() + fake_ur.message_received = AsyncMock(return_value=None) + fake_ur.xmpp = Mock() + fake_ur.xmpp.client = Mock() + fake_ur.xmpp.client.apply_external_reaction = AsyncMock(return_value=None) + client = SignalClient.__new__(SignalClient) + client.service = "signal" + client.ur = fake_ur + client.log = Mock() + client.client = Mock() + # Emulate sync event from our own linked device. + client.client.bot_uuid = "f5f53a90-8b8c-4f0c-9520-8f13aab0b219" + client.client.phone_number = "+15550009999" + client._resolve_signal_identifiers = AsyncMock(return_value=[self.identifier]) + client._auto_link_single_user_signal_identifier = AsyncMock(return_value=[]) + + payload = { + "envelope": { + "sourceNumber": "+15550009999", + "sourceUuid": "f5f53a90-8b8c-4f0c-9520-8f13aab0b219", + "destinationNumber": "+15550002000", + "timestamp": 1772545465000, + "syncMessage": { + "sentMessage": { + "destinationNumber": "+15550002000", + "message": { + "reaction": { + "emoji": "🔥", + "targetSentTimestamp": 1772545458187, + } + } + } + }, + } + } + async_to_sync(client._process_raw_inbound_event)(json.dumps(payload)) + + self.anchor.refresh_from_db() + reactions = list((self.anchor.receipt_payload or {}).get("reactions") or []) + self.assertTrue( + any(str(row.get("emoji") or "") == "🔥" for row in reactions), + "Expected sync reaction to be applied via destination-number fallback resolution.", + ) + + +class SignalRuntimeCommandWritebackTests(TestCase): + def setUp(self): + self.user = User.objects.create_user( + username="signal-runtime-writeback-user", + email="signal-runtime-writeback@example.com", + password="x", + ) + self.person = Person.objects.create(user=self.user, name="Signal Runtime") + self.identifier = PersonIdentifier.objects.create( + user=self.user, + person=self.person, + service="signal", + identifier="+15550003000", + ) + self.session = ChatSession.objects.create(user=self.user, identifier=self.identifier) + self.message = Message.objects.create( + user=self.user, + session=self.session, + sender_uuid="", + custom_author="USER", + text="queued signal send", + ts=1772545467000, + delivered_ts=None, + source_service="web", + source_message_id="1772545467000", + source_chat_id="+15550003000", + ) + + def test_execute_runtime_send_updates_legacy_message_ids(self): + client = SignalClient.__new__(SignalClient) + client.service = "signal" + client.log = Mock() + command = { + "id": "cmd-1", + "action": "send_message_raw", + "payload": { + "recipient": "+15550003000", + "text": "queued signal send", + "attachments": [], + "metadata": {"legacy_message_id": str(self.message.id)}, + }, + } + with patch( + "core.clients.signal.signalapi.send_message_raw", + new=AsyncMock(return_value=1772545467999), + ): + async_to_sync(client._execute_runtime_command)(command) + + self.message.refresh_from_db() + self.assertEqual(1772545467999, int(self.message.delivered_ts or 0)) + self.assertEqual("1772545467999", str(self.message.source_message_id or ""))