diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py index 34fcff19..ff3d83da 100644 --- a/meshchatx/meshchat.py +++ b/meshchatx/meshchat.py @@ -4018,9 +4018,17 @@ class ReticulumMeshChat: return if ctx.telephone_manager and ctx.telephone_manager.initiation_status: + # Outgoing dial owns the line. Reject the inbound caller so they are + # not left ringing while we ignore the callback locally. print( - "on_incoming_telephone_call: Ignoring as we are currently initiating an outgoing call.", + "on_incoming_telephone_call: Rejecting as we are currently initiating an outgoing call.", ) + telephone = getattr(ctx.telephone_manager, "telephone", None) + if telephone: + threading.Timer( + 0.5, + lambda t=telephone: t.hangup(), + ).start() return caller_hash = caller_identity.hash.hex() @@ -4054,15 +4062,7 @@ class ReticulumMeshChat: ctx.config.telephone_allow_calls_from_contacts_only.get() or ctx.config.block_all_from_strangers.get() ): - contact = None - try: - contact = ctx.database.contacts.get_contact_by_identity_hash( - caller_hash - ) - except Exception: - # Treat lookup failure as non-contact to avoid accidentally allowing spam - pass - if not contact: + if not self._is_contact(caller_hash, context=ctx): print(f"Rejecting incoming call from non-contact: {caller_hash}") telephone = getattr(ctx.telephone_manager, "telephone", None) if telephone: @@ -4078,13 +4078,7 @@ class ReticulumMeshChat: print(f"on_incoming_telephone_call: {caller_identity.hash.hex()}") ch = caller_identity.hash.hex() caller_name = (self.get_name_for_identity_hash(ch) or "").strip() or "Mesh" - is_contact = False - try: - is_contact = ( - ctx.database.contacts.get_contact_by_identity_hash(ch) is not None - ) - except Exception: - pass + is_contact = self._is_contact(ch, context=ctx) AsyncUtils.run_async( self.websocket_broadcast( json.dumps( @@ -4181,16 +4175,11 @@ class ReticulumMeshChat: is_filtered = False if ctx.config.do_not_disturb_enabled.get(): is_filtered = True - elif ctx.config.telephone_allow_calls_from_contacts_only.get(): - try: - contact = ctx.database.contacts.get_contact_by_identity_hash( - remote_identity_hash, - ) - if not contact: - is_filtered = True - except Exception: - # Treat lookup failure as filtered to avoid leaking missed-call noise - is_filtered = True + elif ( + ctx.config.telephone_allow_calls_from_contacts_only.get() + or ctx.config.block_all_from_strangers.get() + ) and not self._is_contact(remote_identity_hash, context=ctx): + is_filtered = True if not is_filtered: AsyncUtils.run_async( @@ -9435,16 +9424,14 @@ class ReticulumMeshChat: if self.config.do_not_disturb_enabled.get(): # Don't report active call if DND is on and it's ringing telephone_active_call = None - elif self.config.telephone_allow_calls_from_contacts_only.get(): + elif ( + self.config.telephone_allow_calls_from_contacts_only.get() + or self.config.block_all_from_strangers.get() + ): remote_identity = telephone_active_call.get_remote_identity() if remote_identity: caller_hash = remote_identity.hash.hex() - contact = ( - self.database.contacts.get_contact_by_identity_hash( - caller_hash, - ) - ) - if not contact: + if not self._is_contact(caller_hash): # Don't report active call if contacts-only is on and caller is not a contact telephone_active_call = None else: @@ -9483,9 +9470,7 @@ class ReticulumMeshChat: remote_icon = self.database.misc.get_user_icon(lxmf_destination_hash) # Check if contact and get custom image - contact = self.database.contacts.get_contact_by_identity_hash( - remote_hash, - ) + contact = self._resolve_contact_for_hash(remote_hash) custom_image = contact["custom_image"] if contact else None active_call = { @@ -9573,11 +9558,9 @@ class ReticulumMeshChat: initiation_target_name = None if initiation_target_hash: try: - contact = self.database.contacts.get_contact_by_identity_hash( - initiation_target_hash, - ) + contact = self._resolve_contact_for_hash(initiation_target_hash) if contact: - initiation_target_name = contact.name + initiation_target_name = contact["name"] except Exception: pass @@ -9755,9 +9738,7 @@ class ReticulumMeshChat: if tele_hash: d["remote_telephony_hash"] = tele_hash - contact = self.database.contacts.get_contact_by_identity_hash( - remote_identity_hash, - ) + contact = self._resolve_contact_for_hash(remote_identity_hash) d["is_contact"] = contact is not None if contact: d["contact_image"] = contact.get("custom_image") @@ -10595,28 +10576,69 @@ class ReticulumMeshChat: status=400, ) - if not remote_identity_hash: - # Try to derive identity from LXMF or LXST address - lookup_hash = lxmf_address or lxst_address - if lookup_hash: - announce = self.database.announces.get_announce_by_hash(lookup_hash) - if announce: - remote_identity_hash = announce.get("identity_hash") - else: - # try to recall identity from RNS - ident = self.recall_identity(lookup_hash) - if ident: - remote_identity_hash = ident.hash.hex() + # Normalize: chat UI often posts an LXMF destination hash as + # remote_identity_hash. Prefer the real identity hash when known so + # incoming-call policy (identity hash) matches saved contacts. + provided_hash = remote_identity_hash + lookup_hash = remote_identity_hash or lxmf_address or lxst_address + if lookup_hash: + announce = self.database.announces.get_announce_by_hash(lookup_hash) + if announce and announce.get("identity_hash"): + remote_identity_hash = announce.get("identity_hash") + if not lxmf_address and announce.get("aspect") == "lxmf.delivery": + lxmf_address = announce.get("destination_hash") or lookup_hash + if not lxst_address and announce.get("aspect") == "lxst.telephony": + lxst_address = announce.get("destination_hash") or lookup_hash + else: + ident = self.recall_identity(lookup_hash) + if ident: + remote_identity_hash = ident.hash.hex() if not remote_identity_hash: - # Fallback: use the provided lookup hash directly as identity hash - remote_identity_hash = lxmf_address or lxst_address + remote_identity_hash = lxmf_address or lxst_address or provided_hash if not remote_identity_hash: return web.json_response( {"message": "Identity hash is required or could not be derived"}, status=400, ) + # If the client only supplied a destination hash, keep it on the + # matching address field so lookups by either form succeed. + if provided_hash and provided_hash != remote_identity_hash: + if not lxmf_address: + lxmf_announce = self.database.announces.get_announce_by_hash( + provided_hash, + ) + if lxmf_announce and lxmf_announce.get("aspect") == "lxmf.delivery": + lxmf_address = provided_hash + elif not lxst_address: + lxst_announce = self.database.announces.get_announce_by_hash( + provided_hash, + ) + if ( + lxst_announce + and lxst_announce.get("aspect") == "lxst.telephony" + ): + lxst_address = provided_hash + else: + # Default: treat unknown destination-shaped hashes as LXMF + lxmf_address = lxmf_address or provided_hash + + if not lxmf_address: + try: + lxmf_address = self.get_lxmf_destination_hash_for_identity_hash( + remote_identity_hash, + ) + except Exception: + pass + if not lxst_address: + try: + lxst_address = self.get_lxst_telephony_hash_for_identity_hash( + remote_identity_hash, + ) + except Exception: + pass + self.database.contacts.add_contact( name, remote_identity_hash, @@ -10663,7 +10685,7 @@ class ReticulumMeshChat: @routes.get("/api/v1/telephone/contacts/check/{identity_hash}") async def telephone_contacts_check(request): identity_hash = request.match_info["identity_hash"] - contact = self.database.contacts.get_contact_by_identity_hash(identity_hash) + contact = self._resolve_contact_for_hash(identity_hash) return web.json_response( { "is_contact": contact is not None, @@ -19091,15 +19113,82 @@ class ReticulumMeshChat: background_colour, ) - def _is_contact(self, source_hash: str, context=None) -> bool: + def _related_hashes_for_contact_lookup(self, source_hash: str, context=None): + """Collect identity/LXMF/LXST hashes that may identify the same peer.""" ctx = context or self.current_context + related = [] + seen = set() + + def add(value): + if not value or not isinstance(value, str): + return + normalized = normalize_hex_identifier(value) + if not normalized or normalized in seen: + return + seen.add(normalized) + related.append(normalized) + + add(source_hash) if not ctx or not ctx.database: - return False + return related + try: - contact = ctx.database.contacts.get_contact_by_identity_hash(source_hash) - return contact is not None + announce = ctx.database.announces.get_announce_by_hash(source_hash) + if announce: + add(announce.get("identity_hash")) + add(announce.get("destination_hash")) + identity_hash = announce.get("identity_hash") + if identity_hash: + for other in ctx.database.announces.get_announces_by_identity_hash( + identity_hash, + ): + add(other.get("destination_hash")) + add(other.get("identity_hash")) + else: + for other in ctx.database.announces.get_announces_by_identity_hash( + source_hash, + ): + add(other.get("destination_hash")) + add(other.get("identity_hash")) except Exception: - return False + pass + + try: + lxmf_hash = self.get_lxmf_destination_hash_for_identity_hash(source_hash) + add(lxmf_hash) + except Exception: + pass + + try: + lxst_hash = self.get_lxst_telephony_hash_for_identity_hash(source_hash) + add(lxst_hash) + except Exception: + pass + + return related + + def _resolve_contact_for_hash(self, source_hash: str, context=None): + """Resolve a contact for an identity or destination hash. + + Contacts are often saved with an LXMF destination hash as + ``remote_identity_hash`` (from chat UI). Incoming calls provide the + caller's identity hash. Bridge those forms via announces and derived + destination hashes so contacts-only call policy works. + """ + ctx = context or self.current_context + if not ctx or not ctx.database or not source_hash: + return None + try: + related = self._related_hashes_for_contact_lookup(source_hash, context=ctx) + return ctx.database.contacts.get_contact_by_identity_hash( + source_hash, + related_hashes=related, + ) + except Exception: + return None + + def _is_contact(self, source_hash: str, context=None) -> bool: + return self._resolve_contact_for_hash(source_hash, context=context) is not None def _encode_pcm_wav_to_ogg_opus(self, wav_bytes: bytes) -> bytes | None: """Encode a WAV/PCM payload into an OGG/Opus byte string. diff --git a/meshchatx/src/backend/database/contacts.py b/meshchatx/src/backend/database/contacts.py index 171b1383..af3bbcf5 100644 --- a/meshchatx/src/backend/database/contacts.py +++ b/meshchatx/src/backend/database/contacts.py @@ -132,8 +132,38 @@ class ContactsDAO: def delete_contact(self, contact_id): self.provider.execute("DELETE FROM contacts WHERE id = ?", (contact_id,)) - def get_contact_by_identity_hash(self, remote_identity_hash): + def get_contact_by_identity_hash(self, remote_identity_hash, related_hashes=None): + """Match a contact by identity, LXMF, or LXST hash. + + ``related_hashes`` may include derived destination hashes for the same + peer so callers that only know an identity hash still match contacts + that were saved with an LXMF or LXST destination hash as the primary key. + Matching is case-insensitive. Hex-only forms are also tried so UUID-style + separators still match stored RNS hashes. + """ + if not remote_identity_hash and not related_hashes: + return None + + candidates = [] + for value in (remote_identity_hash, *(related_hashes or ())): + if not value or not isinstance(value, str): + continue + lowered = value.strip().lower() + if lowered and lowered not in candidates: + candidates.append(lowered) + hex_only = "".join(c for c in lowered if c in "0123456789abcdef") + if hex_only and hex_only not in candidates: + candidates.append(hex_only) + if not candidates: + return None + + placeholders = ", ".join("?" for _ in candidates) return self.provider.fetchone( - "SELECT * FROM contacts WHERE remote_identity_hash = ? OR lxmf_address = ? OR lxst_address = ?", - (remote_identity_hash, remote_identity_hash, remote_identity_hash), + f""" + SELECT * FROM contacts + WHERE lower(remote_identity_hash) IN ({placeholders}) + OR lower(lxmf_address) IN ({placeholders}) + OR lower(lxst_address) IN ({placeholders}) + """, + tuple(candidates) * 3, ) diff --git a/meshchatx/src/backend/voicemail_manager.py b/meshchatx/src/backend/voicemail_manager.py index 5c85ce63..29b7c6aa 100644 --- a/meshchatx/src/backend/voicemail_manager.py +++ b/meshchatx/src/backend/voicemail_manager.py @@ -171,7 +171,23 @@ class VoicemailManager: RNS.log("Voicemail: Voicemail is disabled", RNS.LOG_DEBUG) return - if self.db and self.db.misc.is_destination_blocked(caller_identity.hash.hex()): + caller_hash = caller_identity.hash.hex() + is_blocked = False + if self.db: + try: + if self.db.misc.is_destination_blocked(caller_hash): + is_blocked = True + else: + for ann in self.db.announces.get_announces_by_identity_hash( + caller_hash, + ): + dest = ann.get("destination_hash") + if dest and self.db.misc.is_destination_blocked(dest): + is_blocked = True + break + except Exception: + is_blocked = False + if is_blocked: RNS.log( f"Voicemail: Caller {RNS.prettyhexrep(caller_identity.hash)} is blocked; skipping auto-answer", RNS.LOG_DEBUG, diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue index 0e334beb..97159768 100644 --- a/meshchatx/src/frontend/components/App.vue +++ b/meshchatx/src/frontend/components/App.vue @@ -1267,7 +1267,11 @@ export default { if (this.config?.do_not_disturb_enabled) { return; } - if (this.config?.telephone_allow_calls_from_contacts_only && !json.is_contact) { + if ( + (this.config?.telephone_allow_calls_from_contacts_only || + this.config?.block_all_from_strangers) && + !json.is_contact + ) { return; } if (this.initiationStatus) { diff --git a/meshchatx/src/frontend/components/call/CallPage.vue b/meshchatx/src/frontend/components/call/CallPage.vue index d3551935..00af0f74 100644 --- a/meshchatx/src/frontend/components/call/CallPage.vue +++ b/meshchatx/src/frontend/components/call/CallPage.vue @@ -2422,6 +2422,8 @@ export default { contactForm: { name: "", remote_identity_hash: "", + lxmf_address: "", + lxst_address: "", }, searchDebounceTimeout: null, isVoicemailSettingsExpanded: false, @@ -3585,8 +3587,9 @@ export default { this.editingContact = null; this.contactForm = { name: entry.remote_identity_name || "", - remote_identity_hash: - entry.remote_telephony_hash || entry.remote_destination_hash || entry.remote_identity_hash, + remote_identity_hash: entry.remote_identity_hash || "", + lxmf_address: entry.remote_destination_hash || "", + lxst_address: entry.remote_telephony_hash || "", preferred_ringtone_id: null, }; this.isContactModalOpen = true; @@ -4245,22 +4248,32 @@ export default { } let hashToCall = identityHash.trim(); - // Accept lxmf:// URIs or pasted text; extract first 64-char hex - const hexMatch = hashToCall.match(/[0-9a-fA-F]{64}/); + // Accept lxmf:// URIs or pasted text; RNS truncated hashes are 32 hex chars + const hexMatch = hashToCall.match(/[0-9a-fA-F]{32,64}/); if (hexMatch) { - hashToCall = hexMatch[0]; + hashToCall = hexMatch[0].slice(0, 32); } hashToCall = hashToCall.toLowerCase(); // Try to resolve name from contacts const contact = this.contacts.find((c) => c.name.toLowerCase() === hashToCall.toLowerCase()); if (contact) { - hashToCall = contact.remote_identity_hash; + hashToCall = + contact.remote_identity_hash || + contact.remote_telephony_hash || + contact.lxst_address || + contact.lxmf_address; } // Provide immediate feedback this.destinationHash = hashToCall; - const targetContact = this.contacts.find((c) => c.remote_identity_hash === hashToCall); + const targetContact = this.contacts.find( + (c) => + c.remote_identity_hash === hashToCall || + c.lxmf_address === hashToCall || + c.lxst_address === hashToCall || + c.remote_telephony_hash === hashToCall + ); this.initiationTargetHash = hashToCall; this.initiationTargetName = targetContact ? targetContact.name : null; this.activeTab = "phone"; diff --git a/meshchatx/src/frontend/components/messages/ConversationDropDownMenu.vue b/meshchatx/src/frontend/components/messages/ConversationDropDownMenu.vue index 1577759d..fc16302c 100644 --- a/meshchatx/src/frontend/components/messages/ConversationDropDownMenu.vue +++ b/meshchatx/src/frontend/components/messages/ConversationDropDownMenu.vue @@ -228,7 +228,8 @@ export default { // create contact first await window.api.post("/api/v1/telephone/contacts", { name: this.peer.display_name, - remote_identity_hash: this.peer.destination_hash, + remote_identity_hash: this.peer.identity_hash || undefined, + lxmf_address: this.peer.destination_hash, is_telemetry_trusted: true, }); await this.fetchContact(); @@ -239,7 +240,7 @@ export default { this.contact.is_telemetry_trusted = newStatus; } GlobalEmitter.emit("contact-updated", { - remote_identity_hash: this.peer.destination_hash, + remote_identity_hash: this.peer.identity_hash || this.peer.destination_hash, is_telemetry_trusted: newStatus, }); DialogUtils.alert( diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue index 0a55623a..ace54d44 100644 --- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue +++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue @@ -3143,11 +3143,12 @@ export default { await window.api.post("/api/v1/telephone/contacts", { name: displayName, lxmf_address: hash, + remote_identity_hash: this.selectedPeer.identity_hash || undefined, }); this.isStrangerPeer = false; this.strangerBannerDismissed = true; GlobalEmitter.emit("contact-updated", { - remote_identity_hash: hash, + remote_identity_hash: this.selectedPeer.identity_hash || hash, }); this.$emit("reload-conversations"); ToastUtils.success(this.$t("contacts.contact_added")); diff --git a/meshchatx/src/frontend/components/messages/MessagesSidebar.vue b/meshchatx/src/frontend/components/messages/MessagesSidebar.vue index 550ed355..d0100f1e 100644 --- a/meshchatx/src/frontend/components/messages/MessagesSidebar.vue +++ b/meshchatx/src/frontend/components/messages/MessagesSidebar.vue @@ -1275,13 +1275,13 @@ export default { async toggleTelemetryTrust(hash) { const contact = this.contextMenu.targetContact; const newStatus = !contact?.is_telemetry_trusted; + const conv = this.conversations.find((c) => c.destination_hash === hash); try { if (!contact) { - // find display name from conversations - const conv = this.conversations.find((c) => c.destination_hash === hash); await window.api.post("/api/v1/telephone/contacts", { name: conv?.display_name || hash.substring(0, 8), - remote_identity_hash: hash, + remote_identity_hash: conv?.identity_hash || undefined, + lxmf_address: hash, is_telemetry_trusted: true, }); } else { @@ -1290,7 +1290,7 @@ export default { }); } GlobalEmitter.emit("contact-updated", { - remote_identity_hash: hash, + remote_identity_hash: conv?.identity_hash || hash, is_telemetry_trusted: newStatus, }); this.contextMenu.show = false; diff --git a/tests/backend/test_contact_hash_resolution.py b/tests/backend/test_contact_hash_resolution.py new file mode 100644 index 00000000..8fb38a88 --- /dev/null +++ b/tests/backend/test_contact_hash_resolution.py @@ -0,0 +1,70 @@ +# SPDX-License-Identifier: 0BSD + +"""Contact resolution across identity / LXMF / LXST hash forms.""" + +from unittest.mock import MagicMock + +from meshchatx.meshchat import ReticulumMeshChat + +IDENTITY = "a1" * 16 +LXMF = "b2" * 16 +LXST = "c3" * 16 + + +def _app_with_db(contact_row=None, announces=None): + app = ReticulumMeshChat.__new__(ReticulumMeshChat) + ctx = MagicMock() + ctx.database = MagicMock() + ctx.database.announces.get_announce_by_hash.return_value = None + ctx.database.announces.get_announces_by_identity_hash.return_value = announces or [] + ctx.database.contacts.get_contact_by_identity_hash.return_value = contact_row + app.current_context = ctx + app.get_lxmf_destination_hash_for_identity_hash = MagicMock(return_value=LXMF) + app.get_lxst_telephony_hash_for_identity_hash = MagicMock(return_value=LXST) + return app + + +def test_related_hashes_include_announced_and_derived_destinations(): + app = _app_with_db( + announces=[ + { + "destination_hash": LXMF, + "identity_hash": IDENTITY, + "aspect": "lxmf.delivery", + }, + ], + ) + related = app._related_hashes_for_contact_lookup(IDENTITY) + assert IDENTITY in related + assert LXMF in related + assert LXST in related + + +def test_is_contact_true_when_saved_under_lxmf_primary_key(): + contact = {"id": 1, "name": "Friend", "remote_identity_hash": LXMF} + + def lookup(primary, related_hashes=None): + keys = {primary, *(related_hashes or ())} + if LXMF in keys: + return contact + return None + + app = _app_with_db() + app.current_context.database.contacts.get_contact_by_identity_hash.side_effect = ( + lookup + ) + app.current_context.database.announces.get_announces_by_identity_hash.return_value = [ + { + "destination_hash": LXMF, + "identity_hash": IDENTITY, + "aspect": "lxmf.delivery", + }, + ] + + assert app._is_contact(IDENTITY) is True + assert app._resolve_contact_for_hash(IDENTITY)["name"] == "Friend" + + +def test_is_contact_false_for_unknown_peer(): + app = _app_with_db(contact_row=None) + assert app._is_contact(IDENTITY) is False diff --git a/tests/backend/test_contacts_display_name_semantics.py b/tests/backend/test_contacts_display_name_semantics.py index ccdb74e3..36cf66bc 100644 --- a/tests/backend/test_contacts_display_name_semantics.py +++ b/tests/backend/test_contacts_display_name_semantics.py @@ -288,6 +288,24 @@ class TestContactsEdgeCases: assert result is not None assert result["name"] == "Via LXST" + def test_get_contact_by_identity_hash_case_insensitive(self, contacts_dao): + contacts_dao.add_contact("Case", "AABB" + "cc" * 14) + result = contacts_dao.get_contact_by_identity_hash("aabb" + "CC" * 14) + assert result is not None + assert result["name"] == "Case" + + def test_get_contact_by_identity_hash_related_hashes(self, contacts_dao): + identity = "a1" * 16 + lxmf = "b2" * 16 + contacts_dao.add_contact("SavedAsLxmf", lxmf, lxmf_address=lxmf) + result = contacts_dao.get_contact_by_identity_hash( + identity, + related_hashes=[lxmf], + ) + assert result is not None + assert result["name"] == "SavedAsLxmf" + assert result["remote_identity_hash"] == lxmf + def test_delete_nonexistent_contact(self, contacts_dao): contacts_dao.delete_contact(99999) diff --git a/tests/backend/test_incoming_call_policy.py b/tests/backend/test_incoming_call_policy.py index 9b8df110..172fa013 100644 --- a/tests/backend/test_incoming_call_policy.py +++ b/tests/backend/test_incoming_call_policy.py @@ -9,6 +9,7 @@ import pytest from meshchatx.meshchat import ReticulumMeshChat CALLER_HASH_HEX = "a1" * 16 +LXMF_DEST_HEX = "b2" * 16 def _caller_identity(): @@ -27,10 +28,15 @@ def policy_app(): ctx.telephone_manager = tm ctx.config = MagicMock() ctx.database = MagicMock() + ctx.database.announces.get_announce_by_hash.return_value = None + ctx.database.announces.get_announces_by_identity_hash.return_value = [] ctx.voicemail_manager = MagicMock() app.current_context = ctx app.is_destination_blocked = MagicMock(return_value=False) app.websocket_broadcast = MagicMock() + app.get_name_for_identity_hash = MagicMock(return_value="Caller") + app.get_lxmf_destination_hash_for_identity_hash = MagicMock(return_value=None) + app.get_lxst_telephony_hash_for_identity_hash = MagicMock(return_value=None) return app @@ -56,8 +62,8 @@ def test_incoming_rejects_when_blocked_uses_delayed_hangup(policy_app): _run_incoming(policy_app, caller) - policy_app.telephone_manager.telephone.hangup.assert_called_once() - policy_app.voicemail_manager.handle_incoming_call.assert_not_called() + policy_app.current_context.telephone_manager.telephone.hangup.assert_called_once() + policy_app.current_context.voicemail_manager.handle_incoming_call.assert_not_called() def test_incoming_dnd_rejects_before_contact_check(policy_app): @@ -115,9 +121,9 @@ def test_contacts_only_rejects_non_contact_uses_identity_lookup(policy_app): async_utils.run_async = MagicMock() _run_incoming(policy_app, caller) - policy_app.current_context.database.contacts.get_contact_by_identity_hash.assert_called_once_with( - CALLER_HASH_HEX, - ) + policy_app.current_context.database.contacts.get_contact_by_identity_hash.assert_called() + call_kwargs = policy_app.current_context.database.contacts.get_contact_by_identity_hash.call_args + assert call_kwargs[0][0] == CALLER_HASH_HEX policy_app.current_context.telephone_manager.telephone.hangup.assert_called_once() policy_app.current_context.voicemail_manager.handle_incoming_call.assert_not_called() async_utils.run_async.assert_not_called() @@ -139,13 +145,10 @@ def test_contacts_only_accepts_matching_contact(policy_app): async_utils.run_async = MagicMock() _run_incoming(policy_app, caller) - # Called twice: once for policy check, once for websocket broadcast is_contact flag - policy_app.current_context.database.contacts.get_contact_by_identity_hash.assert_called_with( - CALLER_HASH_HEX, - ) + policy_app.current_context.database.contacts.get_contact_by_identity_hash.assert_called() assert ( policy_app.current_context.database.contacts.get_contact_by_identity_hash.call_count - == 2 + >= 1 ) policy_app.current_context.voicemail_manager.handle_incoming_call.assert_called_once_with( caller @@ -154,6 +157,42 @@ def test_contacts_only_accepts_matching_contact(policy_app): async_utils.run_async.assert_called_once() +def test_contacts_only_accepts_contact_saved_under_lxmf_destination(policy_app): + """Chat UI often stores LXMF destination hash as remote_identity_hash.""" + policy_app.is_destination_blocked.return_value = False + policy_app.config.do_not_disturb_enabled.get.return_value = False + policy_app.config.telephone_allow_calls_from_contacts_only.get.return_value = True + policy_app.config.block_all_from_strangers.get.return_value = False + policy_app.current_context.database.announces.get_announces_by_identity_hash.return_value = [ + { + "destination_hash": LXMF_DEST_HEX, + "identity_hash": CALLER_HASH_HEX, + "aspect": "lxmf.delivery", + }, + ] + policy_app.get_lxmf_destination_hash_for_identity_hash.return_value = LXMF_DEST_HEX + + def lookup(primary, related_hashes=None): + keys = {primary, *(related_hashes or ())} + if LXMF_DEST_HEX in keys or CALLER_HASH_HEX in keys: + return {"id": 1, "name": "Friend", "remote_identity_hash": LXMF_DEST_HEX} + return None + + policy_app.current_context.database.contacts.get_contact_by_identity_hash.side_effect = lookup + + caller = _caller_identity() + + with patch("meshchatx.meshchat.AsyncUtils") as async_utils: + async_utils.run_async = MagicMock() + _run_incoming(policy_app, caller) + + policy_app.current_context.voicemail_manager.handle_incoming_call.assert_called_once_with( + caller + ) + policy_app.current_context.telephone_manager.telephone.hangup.assert_not_called() + async_utils.run_async.assert_called_once() + + def test_block_all_strangers_uses_same_contact_gate(policy_app): policy_app.is_destination_blocked.return_value = False policy_app.config.do_not_disturb_enabled.get.return_value = False @@ -177,9 +216,7 @@ def test_block_all_strangers_uses_same_contact_gate(policy_app): async_utils.run_async = MagicMock() _run_incoming(policy_app, caller) - policy_app.current_context.database.contacts.get_contact_by_identity_hash.assert_called_once_with( - CALLER_HASH_HEX, - ) + policy_app.current_context.database.contacts.get_contact_by_identity_hash.assert_called() policy_app.current_context.voicemail_manager.handle_incoming_call.assert_not_called() @@ -188,6 +225,7 @@ def test_when_policy_off_stranger_rings(policy_app): policy_app.config.do_not_disturb_enabled.get.return_value = False policy_app.config.telephone_allow_calls_from_contacts_only.get.return_value = False policy_app.config.block_all_from_strangers.get.return_value = False + policy_app.current_context.database.contacts.get_contact_by_identity_hash.return_value = None caller = _caller_identity() @@ -195,24 +233,32 @@ def test_when_policy_off_stranger_rings(policy_app): async_utils.run_async = MagicMock() _run_incoming(policy_app, caller) - # Called once for websocket broadcast is_contact flag even when policy is off - policy_app.current_context.database.contacts.get_contact_by_identity_hash.assert_called_once_with( - CALLER_HASH_HEX, - ) + policy_app.current_context.database.contacts.get_contact_by_identity_hash.assert_called() policy_app.current_context.voicemail_manager.handle_incoming_call.assert_called_once_with( caller ) async_utils.run_async.assert_called_once() -def test_ignores_incoming_while_outgoing_initiation(policy_app): +def test_rejects_incoming_while_outgoing_initiation(policy_app): policy_app.current_context.telephone_manager.initiation_status = "Dialing..." caller = _caller_identity() - with patch("meshchatx.meshchat.AsyncUtils") as async_utils: - async_utils.run_async = MagicMock() - _run_incoming(policy_app, caller) + with patch("meshchatx.meshchat.threading.Timer") as mock_timer: + def run_timer(delay, fn): + fn() + t = MagicMock() + t.start = MagicMock() + return t + + mock_timer.side_effect = run_timer + + with patch("meshchatx.meshchat.AsyncUtils") as async_utils: + async_utils.run_async = MagicMock() + _run_incoming(policy_app, caller) + + policy_app.current_context.telephone_manager.telephone.hangup.assert_called_once() policy_app.current_context.voicemail_manager.handle_incoming_call.assert_not_called() async_utils.run_async.assert_not_called() @@ -224,14 +270,14 @@ def test_uses_passed_context_not_app_current_context(policy_app): policy_app.config.telephone_allow_calls_from_contacts_only.get.return_value = True policy_app.config.block_all_from_strangers.get.return_value = False - # current_context has no contact policy_app.current_context.database.contacts.get_contact_by_identity_hash.return_value = None - # But a different ctx passed to the method DOES have the contact other_ctx = MagicMock() other_ctx.telephone_manager = policy_app.current_context.telephone_manager other_ctx.config = policy_app.config other_ctx.database = MagicMock() + other_ctx.database.announces.get_announce_by_hash.return_value = None + other_ctx.database.announces.get_announces_by_identity_hash.return_value = [] other_ctx.database.contacts.get_contact_by_identity_hash.return_value = { "id": 1, "name": "Friend", @@ -244,10 +290,6 @@ def test_uses_passed_context_not_app_current_context(policy_app): async_utils.run_async = MagicMock() _run_incoming(policy_app, caller, ctx=other_ctx) - # Called twice: once for policy check, once for websocket broadcast is_contact flag - other_ctx.database.contacts.get_contact_by_identity_hash.assert_called_with( - CALLER_HASH_HEX, - ) - assert other_ctx.database.contacts.get_contact_by_identity_hash.call_count == 2 + other_ctx.database.contacts.get_contact_by_identity_hash.assert_called() policy_app.current_context.database.contacts.get_contact_by_identity_hash.assert_not_called() other_ctx.voicemail_manager.handle_incoming_call.assert_called_once_with(caller) diff --git a/tests/backend/test_voicemail_manager_extended.py b/tests/backend/test_voicemail_manager_extended.py index 4e83f17d..7f01c50e 100644 --- a/tests/backend/test_voicemail_manager_extended.py +++ b/tests/backend/test_voicemail_manager_extended.py @@ -201,6 +201,27 @@ def test_voicemail_session_sets_active_flag(mock_deps, temp_dir): assert mock_tel_manager.is_voicemail_session_active is True +def test_handle_incoming_skips_when_lxmf_destination_blocked(mock_deps, temp_dir): + mock_db = MagicMock() + mock_config = MagicMock() + mock_config.voicemail_enabled.get.return_value = True + mock_tel_manager = MagicMock() + vm = VoicemailManager(mock_db, mock_config, mock_tel_manager, temp_dir) + + caller_hash = "a1" * 16 + lxmf_dest = "b2" * 16 + mock_caller = MagicMock() + mock_caller.hash = bytes.fromhex(caller_hash) + mock_db.misc.is_destination_blocked.side_effect = lambda h: h == lxmf_dest + mock_db.announces.get_announces_by_identity_hash.return_value = [ + {"destination_hash": lxmf_dest, "identity_hash": caller_hash}, + ] + + with patch("threading.Thread") as mock_thread: + vm.handle_incoming_call(mock_caller) + mock_thread.assert_not_called() + + def test_stop_recording_clears_active_flag(mock_deps, temp_dir): mock_db = MagicMock() mock_config = MagicMock() diff --git a/tests/frontend/CallPage.test.js b/tests/frontend/CallPage.test.js index bd51e1ea..73a893d8 100644 --- a/tests/frontend/CallPage.test.js +++ b/tests/frontend/CallPage.test.js @@ -260,6 +260,28 @@ describe("CallPage.vue", () => { }); }); + it("call extracts 32-char RNS hash from pasted text", async () => { + const wrapper = mountCallPage(); + await wrapper.vm.$nextTick(); + const hash32 = "ab".repeat(16); + await wrapper.vm.call(`Call me at ${hash32} please`); + expect(axiosMock.get).toHaveBeenCalledWith(`/api/v1/telephone/call/${hash32}`); + }); + + it("addContactFromHistory prefers identity hash over destination hashes", async () => { + const wrapper = mountCallPage(); + await wrapper.vm.$nextTick(); + await wrapper.vm.addContactFromHistory({ + remote_identity_name: "Sam", + remote_identity_hash: "aa".repeat(16), + remote_destination_hash: "bb".repeat(16), + remote_telephony_hash: "cc".repeat(16), + }); + expect(wrapper.vm.contactForm.remote_identity_hash).toBe("aa".repeat(16)); + expect(wrapper.vm.contactForm.lxmf_address).toBe("bb".repeat(16)); + expect(wrapper.vm.contactForm.lxst_address).toBe("cc".repeat(16)); + }); + it("toggleTelephoneAnnounceEnabled patches config", async () => { const wrapper = mountCallPage(); await wrapper.vm.$nextTick();