From 8c0f4573fdfaf630635db0bf59e1645ca156e054 Mon Sep 17 00:00:00 2001 From: Sudo-Ivan Date: Fri, 2 Jan 2026 01:20:00 -0600 Subject: [PATCH] feat(telephony): enhance call handling with DND and contacts-only filters, add voicemail greeting recording endpoints, and improve notification management --- meshchatx/meshchat.py | 333 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 300 insertions(+), 33 deletions(-) diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py index ba915c0..8a4f4bc 100644 --- a/meshchatx/meshchat.py +++ b/meshchatx/meshchat.py @@ -26,6 +26,23 @@ import LXMF import LXST import psutil import RNS + +# Patch LXST LinkSource to have a samplerate attribute if missing +# This avoids AttributeError in sinks that expect it +try: + import LXST.Network + + if hasattr(LXST.Network, "LinkSource"): + original_init = LXST.Network.LinkSource.__init__ + + def patched_init(self, *args, **kwargs): + self.samplerate = 48000 # Default fallback + original_init(self, *args, **kwargs) + + LXST.Network.LinkSource.__init__ = patched_init +except Exception as e: + print(f"Failed to patch LXST LinkSource: {e}") + import RNS.vendor.umsgpack as msgpack from aiohttp import WSCloseCode, WSMessage, WSMsgType, web from aiohttp_session import get_session @@ -1421,9 +1438,23 @@ class ReticulumMeshChat: if self.config.do_not_disturb_enabled.get(): print(f"Rejecting incoming call due to Do Not Disturb: {caller_hash}") if self.telephone_manager.telephone: - self.telephone_manager.telephone.hangup() + # Use a small delay to ensure LXST state is ready for hangup + threading.Timer( + 0.5, lambda: self.telephone_manager.telephone.hangup() + ).start() return + # Check if only allowing calls from contacts + if self.config.telephone_allow_calls_from_contacts_only.get(): + contact = self.database.contacts.get_contact_by_identity_hash(caller_hash) + if not contact: + print(f"Rejecting incoming call from non-contact: {caller_hash}") + if self.telephone_manager.telephone: + threading.Timer( + 0.5, lambda: self.telephone_manager.telephone.hangup() + ).start() + return + # Trigger voicemail handling self.voicemail_manager.handle_incoming_call(caller_identity) @@ -1492,19 +1523,42 @@ class ReticulumMeshChat: # Trigger missed call notification if it was an incoming call that ended while ringing if is_incoming and status_code == 4: - AsyncUtils.run_async( - self.websocket_broadcast( - json.dumps( - { - "type": "telephone_missed_call", - "remote_identity_hash": remote_identity_hash, - "remote_identity_name": remote_identity_name, - "timestamp": time.time(), - }, - ), - ), + # Check if we should suppress the notification/websocket message + # If DND was on, we still record it but maybe skip the noisy websocket? + # Actually, persistent notification is good. + + self.database.misc.add_notification( + type="telephone_missed_call", + remote_hash=remote_identity_hash, + title="Missed Call", + content=f"You missed a call from {remote_identity_name or remote_identity_hash}", ) + # Skip websocket broadcast if DND or contacts-only was likely the reason + is_filtered = False + if self.config.do_not_disturb_enabled.get(): + is_filtered = True + elif self.config.telephone_allow_calls_from_contacts_only.get(): + contact = self.database.contacts.get_contact_by_identity_hash( + remote_identity_hash + ) + if not contact: + is_filtered = True + + if not is_filtered: + AsyncUtils.run_async( + self.websocket_broadcast( + json.dumps( + { + "type": "telephone_missed_call", + "remote_identity_hash": remote_identity_hash, + "remote_identity_name": remote_identity_name, + "timestamp": time.time(), + }, + ), + ), + ) + AsyncUtils.run_async( self.websocket_broadcast( json.dumps( @@ -3111,6 +3165,24 @@ class ReticulumMeshChat: # get active call info active_call = None telephone_active_call = self.telephone_manager.telephone.active_call + if telephone_active_call is not None: + # Filter out incoming calls if DND or contacts-only is active and call is ringing + is_ringing = self.telephone_manager.telephone.call_status == 4 + if telephone_active_call.is_incoming and is_ringing: + 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(): + caller_hash = ( + telephone_active_call.get_remote_identity().hash.hex() + ) + contact = self.database.contacts.get_contact_by_identity_hash( + caller_hash + ) + if not contact: + # Don't report active call if contacts-only is on and caller is not a contact + telephone_active_call = None + if telephone_active_call is not None: # get remote identity hash remote_identity_hash = None @@ -3432,10 +3504,23 @@ class ReticulumMeshChat: "has_espeak": self.voicemail_manager.has_espeak, "has_ffmpeg": self.voicemail_manager.has_ffmpeg, "is_recording": self.voicemail_manager.is_recording, + "is_greeting_recording": self.voicemail_manager.is_greeting_recording, "has_greeting": os.path.exists(greeting_path), }, ) + # start recording greeting from mic + @routes.post("/api/v1/telephone/voicemail/greeting/record/start") + async def telephone_voicemail_greeting_record_start(request): + self.voicemail_manager.start_greeting_recording() + return web.json_response({"message": "Started recording greeting"}) + + # stop recording greeting from mic + @routes.post("/api/v1/telephone/voicemail/greeting/record/stop") + async def telephone_voicemail_greeting_record_stop(request): + self.voicemail_manager.stop_greeting_recording() + return web.json_response({"message": "Stopped recording greeting"}) + # list voicemails @routes.get("/api/v1/telephone/voicemails") async def telephone_voicemails(request): @@ -3833,28 +3918,47 @@ class ReticulumMeshChat: aspect=aspect, identity_hash=identity_hash, destination_hash=destination_hash, - query=search_query, + query=None, # We filter in Python to support name search blocked_identity_hashes=blocked_identity_hashes, ) + # process all announces to get display names and associated LXMF hashes + all_announces = [ + self.convert_db_announce_to_dict(announce) for announce in results + ] + + # apply search query filter if provided + if search_query: + q = search_query.lower() + filtered = [] + for a in all_announces: + if ( + (a.get("display_name") and q in a["display_name"].lower()) + or ( + a.get("destination_hash") + and q in a["destination_hash"].lower() + ) + or (a.get("identity_hash") and q in a["identity_hash"].lower()) + or ( + a.get("lxmf_destination_hash") + and q in a["lxmf_destination_hash"].lower() + ) + ): + filtered.append(a) + all_announces = filtered + # apply pagination - total_count = len(results) + total_count = len(all_announces) if offset is not None or limit is not None: start = int(offset) if offset else 0 end = start + int(limit) if limit else total_count - paginated_results = results[start:end] + paginated_results = all_announces[start:end] else: - paginated_results = results - - # process announces - announces = [ - self.convert_db_announce_to_dict(announce) - for announce in paginated_results - ] + paginated_results = all_announces return web.json_response( { - "announces": announces, + "announces": paginated_results, "total_count": total_count, }, ) @@ -5314,15 +5418,17 @@ class ReticulumMeshChat: async def notifications_mark_as_viewed(request): data = await request.json() destination_hashes = data.get("destination_hashes", []) + notification_ids = data.get("notification_ids", []) - if not destination_hashes: - return web.json_response( - {"error": "destination_hashes is required"}, - status=400, + if destination_hashes: + # mark LXMF conversations as viewed + self.database.messages.mark_all_notifications_as_viewed( + destination_hashes ) - # mark all notifications as viewed - self.database.messages.mark_all_notifications_as_viewed(destination_hashes) + if notification_ids: + # mark system notifications as viewed + self.database.misc.mark_notifications_as_viewed(notification_ids) return web.json_response( { @@ -5330,6 +5436,148 @@ class ReticulumMeshChat: }, ) + @routes.get("/api/v1/notifications") + async def notifications_get(request): + try: + filter_unread = ReticulumMeshChat.parse_bool_query_param( + request.query.get("unread", "false") + ) + limit = int(request.query.get("limit", 50)) + + # 1. Fetch system notifications + system_notifications = self.database.misc.get_notifications( + filter_unread=filter_unread, limit=limit + ) + + # 2. Fetch unread LXMF conversations if requested + conversations = [] + if filter_unread: + local_hash = self.local_lxmf_destination.hexhash + db_conversations = self.message_handler.get_conversations( + local_hash, filter_unread=True + ) + for db_message in db_conversations: + # Convert to dict if needed + if not isinstance(db_message, dict): + db_message = dict(db_message) + + # determine other user hash + if db_message["source_hash"] == local_hash: + other_user_hash = db_message["destination_hash"] + else: + other_user_hash = db_message["source_hash"] + + # Determine display name + display_name = self.get_name_for_lxmf_destination_hash( + other_user_hash + ) + custom_display_name = ( + self.database.announces.get_custom_display_name( + other_user_hash + ) + ) + + # Determine latest message data + latest_message_data = { + "content": db_message.get("content", ""), + "timestamp": db_message.get("timestamp", 0), + "is_incoming": db_message.get("is_incoming") == 1, + } + + icon = self.database.misc.get_user_icon(other_user_hash) + + conversations.append( + { + "type": "lxmf_message", + "destination_hash": other_user_hash, + "display_name": display_name, + "custom_display_name": custom_display_name, + "lxmf_user_icon": dict(icon) if icon else None, + "latest_message_preview": latest_message_data[ + "content" + ][:100], + "updated_at": datetime.fromtimestamp( + latest_message_data["timestamp"] or 0, UTC + ).isoformat(), + } + ) + + # Combine and sort by timestamp + all_notifications = [] + + for n in system_notifications: + # Convert to dict if needed + if not isinstance(n, dict): + n = dict(n) + + # Get remote user info if possible + display_name = "Unknown" + icon = None + if n["remote_hash"]: + # Try to find associated LXMF hash for telephony identity hash + lxmf_hash = self.get_lxmf_destination_hash_for_identity_hash( + n["remote_hash"] + ) + if not lxmf_hash: + # Fallback to direct name lookup by identity hash + display_name = ( + self.get_name_for_identity_hash(n["remote_hash"]) + or n["remote_hash"] + ) + else: + display_name = self.get_name_for_lxmf_destination_hash( + lxmf_hash + ) + icon = self.database.misc.get_user_icon(lxmf_hash) + + all_notifications.append( + { + "id": n["id"], + "type": n["type"], + "destination_hash": n["remote_hash"], + "display_name": display_name, + "lxmf_user_icon": dict(icon) if icon else None, + "title": n["title"], + "content": n["content"], + "is_viewed": n["is_viewed"] == 1, + "updated_at": datetime.fromtimestamp( + n["timestamp"] or 0, UTC + ).isoformat(), + } + ) + + all_notifications.extend(conversations) + + # Sort by updated_at descending + all_notifications.sort(key=lambda x: x["updated_at"], reverse=True) + + # Calculate actual unread count + unread_count = self.database.misc.get_unread_notification_count() + + # Add LXMF unread count + lxmf_unread_count = 0 + local_hash = self.local_lxmf_destination.hexhash + unread_conversations = self.message_handler.get_conversations( + local_hash, filter_unread=True + ) + if unread_conversations: + lxmf_unread_count = len(unread_conversations) + + total_unread_count = unread_count + lxmf_unread_count + + return web.json_response( + { + "notifications": all_notifications[:limit], + "unread_count": total_unread_count, + } + ) + except Exception as e: + RNS.log(f"Error in notifications_get: {e}", RNS.LOG_ERROR) + import traceback + + traceback.print_exc() + return web.json_response({"error": str(e)}, status=500) + # get blocked destinations @routes.get("/api/v1/blocked-destinations") async def blocked_destinations_get(request): @@ -6966,6 +7214,10 @@ class ReticulumMeshChat: # convert database announce to a dictionary def convert_db_announce_to_dict(self, announce): + # convert to dict if it's a sqlite3.Row + if not isinstance(announce, dict): + announce = dict(announce) + # parse display name from announce display_name = None if announce["aspect"] == "lxmf.delivery": @@ -7001,6 +7253,13 @@ class ReticulumMeshChat: try: identity_hash_bytes = bytes.fromhex(announce["identity_hash"]) identity = RNS.Identity.recall(identity_hash_bytes) + if not identity and announce.get("identity_public_key"): + # Try to load from public key if recall failed + public_key = base64.b64decode(announce["identity_public_key"]) + identity = RNS.Identity(create_keys=False) + if not identity.load_public_key(public_key): + identity = None + if identity: lxmf_destination_hash = RNS.Destination.hash( identity, @@ -7012,10 +7271,18 @@ class ReticulumMeshChat: # find lxmf user icon from database lxmf_user_icon = None - user_icon_target_hash = lxmf_destination_hash or announce["destination_hash"] - db_lxmf_user_icon = self.database.misc.get_user_icon( - user_icon_target_hash, - ) + # Try multiple potential hashes for the icon + icon_hashes_to_check = [] + if lxmf_destination_hash: + icon_hashes_to_check.append(lxmf_destination_hash) + icon_hashes_to_check.append(announce["destination_hash"]) + + db_lxmf_user_icon = None + for icon_hash in icon_hashes_to_check: + db_lxmf_user_icon = self.database.misc.get_user_icon(icon_hash) + if db_lxmf_user_icon: + break + if db_lxmf_user_icon: lxmf_user_icon = { "icon_name": db_lxmf_user_icon["icon_name"],