mirror of
https://github.com/Quad4-Software/MeshChatX.git
synced 2026-08-18 09:49:09 -04:00
feat(call-management): update incoming call handling by implementing identity hash resolution for contacts, improving call rejection logic, and adding support for LXMF and LXST address matching in contact lookups
This commit is contained in:
parent
1184fa47bd
commit
29d91785fb
13 changed files with 438 additions and 111 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
70
tests/backend/test_contact_hash_resolution.py
Normal file
70
tests/backend/test_contact_hash_resolution.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue