diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py index 6f97b4a..1367e5f 100644 --- a/meshchatx/meshchat.py +++ b/meshchatx/meshchat.py @@ -2932,8 +2932,12 @@ class ReticulumMeshChat: # Reject all calls if telephony is disabled if not ctx.config.telephone_enabled.get(): - if ctx.telephone_manager.telephone: - ctx.telephone_manager.telephone.hangup() + telephone = getattr(ctx.telephone_manager, "telephone", None) + if telephone: + threading.Timer( + 0.5, + lambda t=telephone: t.hangup(), + ).start() return if ctx.telephone_manager and ctx.telephone_manager.initiation_status: @@ -2947,18 +2951,24 @@ class ReticulumMeshChat: # Check if caller is blocked if self.is_destination_blocked(caller_hash, context=ctx): print(f"Rejecting incoming call from blocked source: {caller_hash}") - if ctx.telephone_manager.telephone: - ctx.telephone_manager.telephone.hangup() + telephone = getattr(ctx.telephone_manager, "telephone", None) + if telephone: + # Use a small delay to avoid deadlocking with LXST call_handler_lock + threading.Timer( + 0.5, + lambda t=telephone: t.hangup(), + ).start() return # Check for Do Not Disturb if ctx.config.do_not_disturb_enabled.get(): print(f"Rejecting incoming call due to Do Not Disturb: {caller_hash}") - if ctx.telephone_manager.telephone: + telephone = getattr(ctx.telephone_manager, "telephone", None) + if telephone: # Use a small delay to ensure LXST state is ready for hangup threading.Timer( 0.5, - lambda: ctx.telephone_manager.telephone.hangup(), + lambda t=telephone: t.hangup(), ).start() return @@ -2967,13 +2977,21 @@ class ReticulumMeshChat: ctx.config.telephone_allow_calls_from_contacts_only.get() or ctx.config.block_all_from_strangers.get() ): - contact = ctx.database.contacts.get_contact_by_identity_hash(caller_hash) + 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: print(f"Rejecting incoming call from non-contact: {caller_hash}") - if ctx.telephone_manager.telephone: + telephone = getattr(ctx.telephone_manager, "telephone", None) + if telephone: threading.Timer( 0.5, - lambda: ctx.telephone_manager.telephone.hangup(), + lambda t=telephone: t.hangup(), ).start() return @@ -2983,6 +3001,13 @@ 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 AsyncUtils.run_async( self.websocket_broadcast( json.dumps( @@ -2990,6 +3015,7 @@ class ReticulumMeshChat: "type": "telephone_ringing", "remote_identity_hash": ch, "remote_identity_name": caller_name, + "is_contact": is_contact, }, ), ), @@ -3079,10 +3105,14 @@ class ReticulumMeshChat: if ctx.config.do_not_disturb_enabled.get(): is_filtered = True elif ctx.config.telephone_allow_calls_from_contacts_only.get(): - contact = ctx.database.contacts.get_contact_by_identity_hash( - remote_identity_hash, - ) - if not contact: + 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 if not is_filtered: diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue index 71c51e7..3fc4cfd 100644 --- a/meshchatx/src/frontend/components/App.vue +++ b/meshchatx/src/frontend/components/App.vue @@ -1174,7 +1174,9 @@ export default { if (this.config?.do_not_disturb_enabled) { break; } - // If we are the caller (outgoing initiation), skip playing the incoming ringtone + if (this.config?.telephone_allow_calls_from_contacts_only && !json.is_contact) { + break; + } if (this.initiationStatus) { break; } diff --git a/tests/backend/test_incoming_call_policy.py b/tests/backend/test_incoming_call_policy.py index 9b1b510..9b8df11 100644 --- a/tests/backend/test_incoming_call_policy.py +++ b/tests/backend/test_incoming_call_policy.py @@ -39,10 +39,22 @@ def _run_incoming(app, caller, ctx=None): bound(caller, context=ctx) -def test_incoming_rejects_when_blocked_immediate_hangup(policy_app): +def test_incoming_rejects_when_blocked_uses_delayed_hangup(policy_app): policy_app.is_destination_blocked.return_value = True caller = _caller_identity() - _run_incoming(policy_app, caller) + + with patch("meshchatx.meshchat.threading.Timer") as mock_timer: + + def run_timer(delay, fn): + assert delay == 0.5 + fn() + t = MagicMock() + t.start = MagicMock() + return t + + mock_timer.side_effect = run_timer + + _run_incoming(policy_app, caller) policy_app.telephone_manager.telephone.hangup.assert_called_once() policy_app.voicemail_manager.handle_incoming_call.assert_not_called() @@ -127,9 +139,14 @@ def test_contacts_only_accepts_matching_contact(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( + # 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, ) + assert ( + policy_app.current_context.database.contacts.get_contact_by_identity_hash.call_count + == 2 + ) policy_app.current_context.voicemail_manager.handle_incoming_call.assert_called_once_with( caller ) @@ -178,7 +195,10 @@ def test_when_policy_off_stranger_rings(policy_app): async_utils.run_async = MagicMock() _run_incoming(policy_app, caller) - policy_app.current_context.database.contacts.get_contact_by_identity_hash.assert_not_called() + # 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.voicemail_manager.handle_incoming_call.assert_called_once_with( caller ) @@ -224,8 +244,10 @@ def test_uses_passed_context_not_app_current_context(policy_app): async_utils.run_async = MagicMock() _run_incoming(policy_app, caller, ctx=other_ctx) - other_ctx.database.contacts.get_contact_by_identity_hash.assert_called_once_with( + # 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 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)