diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py index 9b9db86..7be3946 100644 --- a/meshchatx/meshchat.py +++ b/meshchatx/meshchat.py @@ -57,13 +57,13 @@ from meshchatx.src.backend.lxmf_message_fields import ( ) from meshchatx.src.backend.map_manager import MapManager from meshchatx.src.backend.message_handler import MessageHandler +from meshchatx.src.backend.ringtone_manager import RingtoneManager from meshchatx.src.backend.rncp_handler import RNCPHandler from meshchatx.src.backend.rnprobe_handler import RNProbeHandler from meshchatx.src.backend.rnstatus_handler import RNStatusHandler from meshchatx.src.backend.sideband_commands import SidebandCommands from meshchatx.src.backend.telemetry_utils import Telemeter from meshchatx.src.backend.telephone_manager import TelephoneManager -from meshchatx.src.backend.ringtone_manager import RingtoneManager from meshchatx.src.backend.translator_handler import TranslatorHandler from meshchatx.src.backend.voicemail_manager import VoicemailManager from meshchatx.src.version import __version__ as app_version @@ -186,13 +186,13 @@ class ReticulumMeshChat: self.auto_recover = auto_recover self.auth_enabled_initial = auth_enabled self.websocket_clients: list[web.WebSocketResponse] = [] - + # track announce timestamps for rate calculation self.announce_timestamps = [] - + # track download speeds for nomadnetwork files self.download_speeds = [] - + # track active downloads self.active_downloads = {} self.download_id_counter = 0 @@ -201,7 +201,7 @@ class ReticulumMeshChat: def setup_identity(self, identity: RNS.Identity): # assign a unique session ID to this identity instance to help background threads exit - if not hasattr(self, '_identity_session_id'): + if not hasattr(self, "_identity_session_id"): self._identity_session_id = 0 self._identity_session_id += 1 session_id = self._identity_session_id @@ -215,8 +215,8 @@ class ReticulumMeshChat: print(f"Using Storage Path: {self.storage_path}") os.makedirs(self.storage_path, exist_ok=True) - # Safety: Before setting up a new identity, ensure no destinations for this identity - # are currently registered in Transport. This prevents the "Attempt to register + # Safety: Before setting up a new identity, ensure no destinations for this identity + # are currently registered in Transport. This prevents the "Attempt to register # an already registered destination" error during hotswap. self.cleanup_rns_state_for_identity(identity.hash) @@ -275,7 +275,7 @@ class ReticulumMeshChat: self.database.messages.mark_stuck_messages_as_failed() # init reticulum - if not hasattr(self, 'reticulum'): + if not hasattr(self, "reticulum"): self.reticulum = RNS.Reticulum(self.reticulum_config_dir) self.identity = identity @@ -606,7 +606,7 @@ class ReticulumMeshChat: def cleanup_rns_state_for_identity(self, identity_hash): if not identity_hash: return - + if isinstance(identity_hash, str): identity_hash_bytes = bytes.fromhex(identity_hash) identity_hash_hex = identity_hash @@ -622,10 +622,10 @@ class ReticulumMeshChat: for destination in list(RNS.Transport.destinations): match = False # check identity hash - if hasattr(destination, 'identity') and destination.identity: + if hasattr(destination, "identity") and destination.identity: if destination.identity.hash == identity_hash_bytes: match = True - + if match: print(f"Deregistering RNS destination {destination} ({RNS.prettyhexrep(destination.hash)})") RNS.Transport.deregister_destination(destination) @@ -637,11 +637,11 @@ class ReticulumMeshChat: for link in list(RNS.Transport.active_links): match = False # check if local identity or destination matches - if hasattr(link, 'destination') and link.destination: - if hasattr(link.destination, 'identity') and link.destination.identity: + if hasattr(link, "destination") and link.destination: + if hasattr(link.destination, "identity") and link.destination.identity: if link.destination.identity.hash == identity_hash_bytes: match = True - + if match: print(f"Tearing down RNS link {link}") try: @@ -654,27 +654,27 @@ class ReticulumMeshChat: def teardown_identity(self): print("Tearing down current identity instance...") self.running = False - + # 1. Deregister destinations and links from RNS Transport try: # Get current identity hash for matching - current_identity_hash = self.identity.hash if hasattr(self, 'identity') and self.identity else None - + current_identity_hash = self.identity.hash if hasattr(self, "identity") and self.identity else None + # Explicitly deregister known destinations from managers first - if hasattr(self, 'message_router') and self.message_router: + if hasattr(self, "message_router") and self.message_router: # Deregister delivery destinations - if hasattr(self.message_router, 'delivery_destinations'): + if hasattr(self.message_router, "delivery_destinations"): for dest_hash in list(self.message_router.delivery_destinations.keys()): dest = self.message_router.delivery_destinations[dest_hash] RNS.Transport.deregister_destination(dest) - + # Deregister propagation destination - if hasattr(self.message_router, 'propagation_destination') and self.message_router.propagation_destination: + if hasattr(self.message_router, "propagation_destination") and self.message_router.propagation_destination: RNS.Transport.deregister_destination(self.message_router.propagation_destination) - if hasattr(self, 'telephone_manager') and self.telephone_manager: - if hasattr(self.telephone_manager, 'telephone') and self.telephone_manager.telephone: - if hasattr(self.telephone_manager.telephone, 'destination') and self.telephone_manager.telephone.destination: + if hasattr(self, "telephone_manager") and self.telephone_manager: + if hasattr(self.telephone_manager, "telephone") and self.telephone_manager.telephone: + if hasattr(self.telephone_manager.telephone, "destination") and self.telephone_manager.telephone.destination: RNS.Transport.deregister_destination(self.telephone_manager.telephone.destination) # Use the global helper for thorough cleanup @@ -688,53 +688,47 @@ class ReticulumMeshChat: try: for handler in list(RNS.Transport.announce_handlers): should_deregister = False - + # check if it's one of our AnnounceHandler instances - if hasattr(handler, 'aspect_filter') and hasattr(handler, 'received_announce_callback'): + if (hasattr(handler, "aspect_filter") and hasattr(handler, "received_announce_callback")) or (hasattr(handler, "router") and hasattr(self, "message_router") and handler.router == self.message_router) or "LXMFDeliveryAnnounceHandler" in str(type(handler)) or "LXMFPropagationAnnounceHandler" in str(type(handler)): should_deregister = True - # LXMF handlers - they usually have a reference to the router - elif hasattr(handler, 'router') and hasattr(self, 'message_router') and handler.router == self.message_router: - should_deregister = True - # generic check for LXMF handlers if the above fails - elif "LXMFDeliveryAnnounceHandler" in str(type(handler)) or "LXMFPropagationAnnounceHandler" in str(type(handler)): - should_deregister = True - + if should_deregister: RNS.Transport.deregister_announce_handler(handler) except Exception as e: print(f"Error while deregistering announce handlers: {e}") # 3. Stop the LXMRouter job loop (hacking it to stop) - if hasattr(self, 'message_router') and self.message_router: + if hasattr(self, "message_router") and self.message_router: try: # Replacing jobs with a no-op so the thread just sleeps self.message_router.jobs = lambda: None - + # Try to call exit_handler to persist state - if hasattr(self.message_router, 'exit_handler'): + if hasattr(self.message_router, "exit_handler"): self.message_router.exit_handler() except Exception as e: print(f"Error while tearing down LXMRouter: {e}") # 4. Stop telephone and voicemail - if hasattr(self, 'telephone_manager') and self.telephone_manager: + if hasattr(self, "telephone_manager") and self.telephone_manager: try: # use teardown instead of shutdown - if hasattr(self.telephone_manager, 'teardown'): + if hasattr(self.telephone_manager, "teardown"): self.telephone_manager.teardown() - elif hasattr(self.telephone_manager, 'shutdown'): + elif hasattr(self.telephone_manager, "shutdown"): self.telephone_manager.shutdown() except Exception as e: print(f"Error while tearing down telephone: {e}") - - if hasattr(self, 'voicemail_manager') and self.voicemail_manager: + + if hasattr(self, "voicemail_manager") and self.voicemail_manager: try: self.voicemail_manager.stop_recording() except Exception: pass # 5. Close database - if hasattr(self, 'database') and self.database: + if hasattr(self, "database") and self.database: try: self.database.close() except Exception: @@ -747,33 +741,33 @@ class ReticulumMeshChat: identity_file = os.path.join(identity_dir, "identity") if not os.path.exists(identity_file): raise ValueError("Identity file not found") - + new_identity = RNS.Identity.from_file(identity_file) - + # 1. teardown old identity self.teardown_identity() - + # Wait a moment for threads to notice self.running=False and destinations to clear await asyncio.sleep(3) - + # 2. update main identity file main_identity_file = self.identity_file_path or os.path.join(self.storage_dir, "identity") import shutil shutil.copy2(identity_file, main_identity_file) - + # 3. reset state and setup new identity self.running = True self.setup_identity(new_identity) - + # 4. broadcast update to clients await self.websocket_broadcast( json.dumps({ "type": "identity_switched", "identity_hash": identity_hash, - "display_name": self.config.display_name.get() - }) + "display_name": self.config.display_name.get(), + }), ) - + return True except Exception as e: print(f"Hotswap failed: {e}") @@ -818,12 +812,12 @@ class ReticulumMeshChat: icon_name = None icon_foreground_colour = None icon_background_colour = None - + try: # use a temporary provider to avoid messing with current DB - from meshchatx.src.backend.database.provider import DatabaseProvider from meshchatx.src.backend.database.config import ConfigDAO - + from meshchatx.src.backend.database.provider import DatabaseProvider + temp_provider = DatabaseProvider(db_path) temp_config_dao = ConfigDAO(temp_provider) display_name = temp_config_dao.get("display_name", "Anonymous Peer") @@ -840,49 +834,49 @@ class ReticulumMeshChat: "icon_name": icon_name, "icon_foreground_colour": icon_foreground_colour, "icon_background_colour": icon_background_colour, - "is_current": identity_hash == self.identity.hash.hex() + "is_current": identity_hash == self.identity.hash.hex(), }) return identities def create_identity(self, display_name=None): new_identity = RNS.Identity(create_keys=True) identity_hash = new_identity.hash.hex() - + identity_dir = os.path.join(self.storage_dir, "identities", identity_hash) os.makedirs(identity_dir, exist_ok=True) - + # save identity file in its own directory identity_file = os.path.join(identity_dir, "identity") with open(identity_file, "wb") as f: f.write(new_identity.get_private_key()) - + # initialize its database and set display name db_path = os.path.join(identity_dir, "database.db") - + # Avoid using the Database class singleton behavior + from meshchatx.src.backend.database.config import ConfigDAO from meshchatx.src.backend.database.provider import DatabaseProvider from meshchatx.src.backend.database.schema import DatabaseSchema - from meshchatx.src.backend.database.config import ConfigDAO - + new_provider = DatabaseProvider(db_path) new_schema = DatabaseSchema(new_provider) new_schema.initialize() - + if display_name: new_config_dao = ConfigDAO(new_provider) new_config_dao.set("display_name", display_name) - + new_provider.close() - + return { "hash": identity_hash, - "display_name": display_name or "Anonymous Peer" + "display_name": display_name or "Anonymous Peer", } def delete_identity(self, identity_hash): if identity_hash == self.identity.hash.hex(): raise ValueError("Cannot delete the current active identity") - + identity_dir = os.path.join(self.storage_dir, "identities", identity_hash) if os.path.exists(identity_dir): import shutil @@ -1454,10 +1448,10 @@ class ReticulumMeshChat: # session secret for encrypted cookies (generate once and store in shared storage) session_secret_path = os.path.join(self.storage_dir, "session_secret") self.session_secret_key = None - + if os.path.exists(session_secret_path): try: - with open(session_secret_path, "r") as f: + with open(session_secret_path) as f: self.session_secret_key = f.read().strip() except Exception as e: print(f"Failed to read session secret from {session_secret_path}: {e}") @@ -1467,7 +1461,7 @@ class ReticulumMeshChat: self.session_secret_key = self.config.auth_session_secret.get() if not self.session_secret_key: self.session_secret_key = secrets.token_urlsafe(32) - + try: with open(session_secret_path, "w") as f: f.write(self.session_secret_key) @@ -1529,7 +1523,7 @@ class ReticulumMeshChat: is_authenticated = session.get("authenticated", False) session_identity = session.get("identity_hash") - + # Check if authenticated AND matches current identity if not is_authenticated or session_identity != self.identity.hash.hex(): if path.startswith("/api/"): @@ -1572,10 +1566,10 @@ class ReticulumMeshChat: session = await get_session(request) is_authenticated = session.get("authenticated", False) session_identity = session.get("identity_hash") - + # Verify that authentication is for the CURRENT active identity actually_authenticated = is_authenticated and (session_identity == self.identity.hash.hex()) - + return web.json_response( { "auth_enabled": self.auth_enabled, @@ -2858,13 +2852,12 @@ class ReticulumMeshChat: "message": "Identity deleted successfully", }, ) - else: - return web.json_response( - { - "message": "Identity not found", - }, - status=404, - ) + return web.json_response( + { + "message": "Identity not found", + }, + status=404, + ) except Exception as e: return web.json_response( { @@ -2878,10 +2871,10 @@ class ReticulumMeshChat: try: data = await request.json() identity_hash = data.get("identity_hash") - + # attempt hotswap first success = await self.hotswap_identity(identity_hash) - + if success: return web.json_response( { @@ -2889,36 +2882,35 @@ class ReticulumMeshChat: "hotswapped": True, }, ) - else: - # fallback to restart if hotswap failed - # (this part should probably be unreachable if hotswap is reliable) - main_identity_file = self.identity_file_path or os.path.join(self.storage_dir, "identity") - identity_dir = os.path.join(self.storage_dir, "identities", identity_hash) - identity_file = os.path.join(identity_dir, "identity") - import shutil - shutil.copy2(identity_file, main_identity_file) - - def restart(): - import sys - import time - import os - time.sleep(1) - try: - os.execv(sys.executable, [sys.executable] + sys.argv) - except Exception as e: - print(f"Failed to restart: {e}") - os._exit(0) - - import threading - threading.Thread(target=restart).start() + # fallback to restart if hotswap failed + # (this part should probably be unreachable if hotswap is reliable) + main_identity_file = self.identity_file_path or os.path.join(self.storage_dir, "identity") + identity_dir = os.path.join(self.storage_dir, "identities", identity_hash) + identity_file = os.path.join(identity_dir, "identity") + import shutil + shutil.copy2(identity_file, main_identity_file) - return web.json_response( - { - "message": "Identity switch scheduled. Application will restart.", - "hotswapped": False, - "should_restart": True, - }, - ) + def restart(): + import os + import sys + import time + time.sleep(1) + try: + os.execv(sys.executable, [sys.executable] + sys.argv) + except Exception as e: + print(f"Failed to restart: {e}") + os._exit(0) + + import threading + threading.Thread(target=restart).start() + + return web.json_response( + { + "message": "Identity switch scheduled. Application will restart.", + "hotswapped": False, + "should_restart": True, + }, + ) except Exception as e: return web.json_response( { @@ -3147,7 +3139,7 @@ class ReticulumMeshChat: async def telephone_history(request): limit = int(request.query.get("limit", 10)) history = self.database.telephone.get_call_history(limit=limit) - + call_history = [] for row in history: d = dict(row) @@ -3312,9 +3304,11 @@ class ReticulumMeshChat: # list voicemails @routes.get("/api/v1/telephone/voicemails") async def telephone_voicemails(request): + search = request.query.get("search") limit = int(request.query.get("limit", 50)) offset = int(request.query.get("offset", 0)) voicemails_rows = self.database.voicemails.get_voicemails( + search=search, limit=limit, offset=offset, ) @@ -3365,12 +3359,12 @@ class ReticulumMeshChat: @routes.get("/api/v1/telephone/voicemail/greeting/audio") async def telephone_voicemail_greeting_audio(request): filepath = os.path.join( - self.voicemail_manager.greetings_dir, "greeting.opus" + self.voicemail_manager.greetings_dir, "greeting.opus", ) if os.path.exists(filepath): return web.FileResponse(filepath) return web.json_response( - {"message": "Greeting audio not found"}, status=404 + {"message": "Greeting audio not found"}, status=404, ) # serve voicemail audio @@ -3385,6 +3379,7 @@ class ReticulumMeshChat: ) if os.path.exists(filepath): return web.FileResponse(filepath) + RNS.log(f"Voicemail: Recording file missing for ID {voicemail_id}: {filepath}", RNS.LOG_ERROR) return web.json_response( {"message": "Voicemail audio not found"}, status=404, @@ -3413,7 +3408,7 @@ class ReticulumMeshChat: field = await reader.next() if field.name != "file": return web.json_response( - {"message": "File field required"}, status=400 + {"message": "File field required"}, status=400, ) filename = field.filename @@ -3472,7 +3467,7 @@ class ReticulumMeshChat: "created_at": r["created_at"], } for r in ringtones - ] + ], ) @routes.get("/api/v1/telephone/ringtones/status") @@ -3484,7 +3479,7 @@ class ReticulumMeshChat: "enabled": self.config.custom_ringtone_enabled.get(), "filename": primary["filename"] if primary else None, "id": primary["id"] if primary else None, - } + }, ) @routes.get("/api/v1/telephone/ringtones/{id}/audio") @@ -3493,7 +3488,7 @@ class ReticulumMeshChat: ringtone = self.database.ringtones.get_by_id(ringtone_id) if not ringtone: return web.json_response({"message": "Ringtone not found"}, status=404) - + filepath = self.ringtone_manager.get_ringtone_path(ringtone["storage_filename"]) if os.path.exists(filepath): return web.FileResponse(filepath) @@ -3511,7 +3506,7 @@ class ReticulumMeshChat: extension = os.path.splitext(filename)[1].lower() if extension not in [".mp3", ".ogg", ".wav", ".m4a", ".flac"]: return web.json_response( - {"message": f"Unsupported file type: {extension}"}, status=400 + {"message": f"Unsupported file type: {extension}"}, status=400, ) # Save temp file @@ -3529,20 +3524,20 @@ class ReticulumMeshChat: self.ringtone_manager.convert_to_ringtone, temp_path, ) - + # Add to database ringtone_id = self.database.ringtones.add( filename=filename, - storage_filename=storage_filename + storage_filename=storage_filename, ) - + return web.json_response( { - "message": "Ringtone uploaded and converted", + "message": "Ringtone uploaded and converted", "id": ringtone_id, "filename": filename, - "storage_filename": storage_filename - } + "storage_filename": storage_filename, + }, ) finally: if os.path.exists(temp_path): @@ -3556,16 +3551,16 @@ class ReticulumMeshChat: try: ringtone_id = int(request.match_info["id"]) data = await request.json() - + display_name = data.get("display_name") is_primary = 1 if data.get("is_primary") else None - + self.database.ringtones.update( ringtone_id, display_name=display_name, - is_primary=is_primary + is_primary=is_primary, ) - + return web.json_response({"message": "Ringtone updated"}) except Exception as e: return web.json_response({"message": str(e)}, status=500) @@ -3582,6 +3577,60 @@ class ReticulumMeshChat: except Exception as e: return web.json_response({"message": str(e)}, status=500) + # contacts routes + @routes.get("/api/v1/telephone/contacts") + async def telephone_contacts_get(request): + search = request.query.get("search") + limit = int(request.query.get("limit", 100)) + offset = int(request.query.get("offset", 0)) + contacts_rows = self.database.contacts.get_contacts( + search=search, + limit=limit, + offset=offset, + ) + + contacts = [] + for row in contacts_rows: + d = dict(row) + remote_identity_hash = d.get("remote_identity_hash") + if remote_identity_hash: + lxmf_hash = self.get_lxmf_destination_hash_for_identity_hash(remote_identity_hash) + if lxmf_hash: + icon = self.database.misc.get_user_icon(lxmf_hash) + if icon: + d["remote_icon"] = dict(icon) + contacts.append(d) + + return web.json_response(contacts) + + @routes.post("/api/v1/telephone/contacts") + async def telephone_contacts_post(request): + data = await request.json() + name = data.get("name") + remote_identity_hash = data.get("remote_identity_hash") + + if not name or not remote_identity_hash: + return web.json_response({"message": "Name and identity hash required"}, status=400) + + self.database.contacts.add_contact(name, remote_identity_hash) + return web.json_response({"message": "Contact added"}) + + @routes.patch("/api/v1/telephone/contacts/{id}") + async def telephone_contacts_patch(request): + contact_id = int(request.match_info["id"]) + data = await request.json() + name = data.get("name") + remote_identity_hash = data.get("remote_identity_hash") + + self.database.contacts.update_contact(contact_id, name, remote_identity_hash) + return web.json_response({"message": "Contact updated"}) + + @routes.delete("/api/v1/telephone/contacts/{id}") + async def telephone_contacts_delete(request): + contact_id = int(request.match_info["id"]) + self.database.contacts.delete_contact(contact_id) + return web.json_response({"message": "Contact deleted"}) + # announce @routes.get("/api/v1/announce") async def announce_trigger(request): @@ -4921,12 +4970,12 @@ class ReticulumMeshChat: search_query = request.query.get("q", None) filter_unread = ReticulumMeshChat.parse_bool_query_param( request.query.get( - "unread", request.query.get("filter_unread", "false") + "unread", request.query.get("filter_unread", "false"), ), ) filter_failed = ReticulumMeshChat.parse_bool_query_param( request.query.get( - "failed", request.query.get("filter_failed", "false") + "failed", request.query.get("filter_failed", "false"), ), ) filter_has_attachments = ReticulumMeshChat.parse_bool_query_param( @@ -5317,7 +5366,7 @@ class ReticulumMeshChat: if r["physical_link"] else None, "updated_at": r["updated_at"], - } + }, ) return web.json_response({"telemetry": telemetry_list}) @@ -5329,7 +5378,7 @@ class ReticulumMeshChat: offset = int(request.query.get("offset", 0)) results = self.database.telemetry.get_telemetry_history( - destination_hash, limit, offset + destination_hash, limit, offset, ) telemetry_list = [] for r in results: @@ -5343,7 +5392,7 @@ class ReticulumMeshChat: if r["physical_link"] else None, "updated_at": r["updated_at"], - } + }, ) return web.json_response({"telemetry": telemetry_list}) @@ -5365,7 +5414,7 @@ class ReticulumMeshChat: if r["physical_link"] else None, "updated_at": r["updated_at"], - } + }, ) # upload offline map @@ -5542,7 +5591,7 @@ class ReticulumMeshChat: # setup session storage # aiohttp_session.setup must be called before other middlewares that use sessions - + # Ensure we have a valid 32-byte key for Fernet try: # First try decoding as base64 (since secrets.token_urlsafe produces base64) @@ -7444,7 +7493,7 @@ class ReticulumMeshChat: if lat is None or lon is None: print( - f"Cannot respond to telemetry request from {to_addr_hash}: No location set" + f"Cannot respond to telemetry request from {to_addr_hash}: No location set", ) return diff --git a/meshchatx/src/backend/database/__init__.py b/meshchatx/src/backend/database/__init__.py index 0696cbb..76085f5 100644 --- a/meshchatx/src/backend/database/__init__.py +++ b/meshchatx/src/backend/database/__init__.py @@ -1,14 +1,15 @@ from .announces import AnnounceDAO from .config import ConfigDAO +from .contacts import ContactsDAO from .legacy_migrator import LegacyMigrator from .messages import MessageDAO from .misc import MiscDAO from .provider import DatabaseProvider +from .ringtones import RingtoneDAO from .schema import DatabaseSchema from .telemetry import TelemetryDAO from .telephone import TelephoneDAO from .voicemails import VoicemailDAO -from .ringtones import RingtoneDAO class Database: @@ -23,6 +24,7 @@ class Database: self.telemetry = TelemetryDAO(self.provider) self.voicemails = VoicemailDAO(self.provider) self.ringtones = RingtoneDAO(self.provider) + self.contacts = ContactsDAO(self.provider) def initialize(self): self.schema.initialize() diff --git a/meshchatx/src/backend/database/config.py b/meshchatx/src/backend/database/config.py index af86303..3180fe8 100644 --- a/meshchatx/src/backend/database/config.py +++ b/meshchatx/src/backend/database/config.py @@ -18,13 +18,13 @@ class ConfigDAO: self.provider.execute("DELETE FROM config WHERE key = ?", (key,)) else: now = datetime.now(UTC) - + # handle booleans specifically to ensure they are stored as "true"/"false" if isinstance(value, bool): value_str = "true" if value else "false" else: value_str = str(value) - + self.provider.execute( """ INSERT INTO config (key, value, created_at, updated_at) diff --git a/meshchatx/src/backend/database/contacts.py b/meshchatx/src/backend/database/contacts.py new file mode 100644 index 0000000..5c216af --- /dev/null +++ b/meshchatx/src/backend/database/contacts.py @@ -0,0 +1,60 @@ +from .provider import DatabaseProvider + + +class ContactsDAO: + def __init__(self, provider: DatabaseProvider): + self.provider = provider + + def add_contact(self, name, remote_identity_hash): + self.provider.execute( + """ + INSERT INTO contacts (name, remote_identity_hash) + VALUES (?, ?) + ON CONFLICT(remote_identity_hash) DO UPDATE SET + name = EXCLUDED.name, + updated_at = CURRENT_TIMESTAMP + """, + (name, remote_identity_hash), + ) + + def get_contacts(self, search=None, limit=100, offset=0): + if search: + return self.provider.fetchall( + """ + SELECT * FROM contacts + WHERE name LIKE ? OR remote_identity_hash LIKE ? + ORDER BY name ASC LIMIT ? OFFSET ? + """, + (f"%{search}%", f"%{search}%", limit, offset), + ) + return self.provider.fetchall( + "SELECT * FROM contacts ORDER BY name ASC LIMIT ? OFFSET ?", + (limit, offset), + ) + + def get_contact(self, contact_id): + return self.provider.fetchone( + "SELECT * FROM contacts WHERE id = ?", + (contact_id,), + ) + + def update_contact(self, contact_id, name=None, remote_identity_hash=None): + if name and remote_identity_hash: + self.provider.execute( + "UPDATE contacts SET name = ?, remote_identity_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + (name, remote_identity_hash, contact_id), + ) + elif name: + self.provider.execute( + "UPDATE contacts SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + (name, contact_id), + ) + elif remote_identity_hash: + self.provider.execute( + "UPDATE contacts SET remote_identity_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + (remote_identity_hash, contact_id), + ) + + def delete_contact(self, contact_id): + self.provider.execute("DELETE FROM contacts WHERE id = ?", (contact_id,)) + diff --git a/meshchatx/src/backend/database/ringtones.py b/meshchatx/src/backend/database/ringtones.py index 89d342e..1317848 100644 --- a/meshchatx/src/backend/database/ringtones.py +++ b/meshchatx/src/backend/database/ringtones.py @@ -1,6 +1,8 @@ from datetime import UTC, datetime + from .provider import DatabaseProvider + class RingtoneDAO: def __init__(self, provider: DatabaseProvider): self.provider = provider @@ -18,14 +20,14 @@ class RingtoneDAO: now = datetime.now(UTC) if display_name is None: display_name = filename - + # check if this is the first ringtone, if so make it primary count = self.provider.fetchone("SELECT COUNT(*) as count FROM ringtones")["count"] is_primary = 1 if count == 0 else 0 - + cursor = self.provider.execute( "INSERT INTO ringtones (filename, display_name, storage_filename, is_primary, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)", - (filename, display_name, storage_filename, is_primary, now, now) + (filename, display_name, storage_filename, is_primary, now, now), ) return cursor.lastrowid @@ -34,21 +36,21 @@ class RingtoneDAO: if is_primary == 1: # reset others self.provider.execute("UPDATE ringtones SET is_primary = 0, updated_at = ?", (now,)) - + if display_name is not None and is_primary is not None: self.provider.execute( "UPDATE ringtones SET display_name = ?, is_primary = ?, updated_at = ? WHERE id = ?", - (display_name, is_primary, now, ringtone_id) + (display_name, is_primary, now, ringtone_id), ) elif display_name is not None: self.provider.execute( "UPDATE ringtones SET display_name = ?, updated_at = ? WHERE id = ?", - (display_name, now, ringtone_id) + (display_name, now, ringtone_id), ) elif is_primary is not None: self.provider.execute( "UPDATE ringtones SET is_primary = ?, updated_at = ? WHERE id = ?", - (is_primary, now, ringtone_id) + (is_primary, now, ringtone_id), ) def delete(self, ringtone_id): diff --git a/meshchatx/src/backend/database/schema.py b/meshchatx/src/backend/database/schema.py index 89d3e33..17fdbfc 100644 --- a/meshchatx/src/backend/database/schema.py +++ b/meshchatx/src/backend/database/schema.py @@ -2,7 +2,7 @@ from .provider import DatabaseProvider class DatabaseSchema: - LATEST_VERSION = 17 + LATEST_VERSION = 18 def __init__(self, provider: DatabaseProvider): self.provider = provider @@ -238,6 +238,15 @@ class DatabaseSchema: updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ) """, + "contacts": """ + CREATE TABLE IF NOT EXISTS contacts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT, + remote_identity_hash TEXT UNIQUE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + """, } for table_name, create_sql in tables.items(): @@ -525,6 +534,23 @@ class DatabaseSchema: ) """) + if current_version < 18: + self.provider.execute(""" + CREATE TABLE IF NOT EXISTS contacts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT, + remote_identity_hash TEXT UNIQUE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + """) + self.provider.execute( + "CREATE INDEX IF NOT EXISTS idx_contacts_name ON contacts(name)", + ) + self.provider.execute( + "CREATE INDEX IF NOT EXISTS idx_contacts_remote_identity_hash ON contacts(remote_identity_hash)", + ) + # Update version in config self.provider.execute( """ diff --git a/meshchatx/src/backend/database/telemetry.py b/meshchatx/src/backend/database/telemetry.py index 76b2c0f..9f69505 100644 --- a/meshchatx/src/backend/database/telemetry.py +++ b/meshchatx/src/backend/database/telemetry.py @@ -9,7 +9,7 @@ class TelemetryDAO: self.provider = provider def upsert_telemetry( - self, destination_hash, timestamp, data, received_from=None, physical_link=None + self, destination_hash, timestamp, data, received_from=None, physical_link=None, ): now = datetime.now(UTC).isoformat() diff --git a/meshchatx/src/backend/database/voicemails.py b/meshchatx/src/backend/database/voicemails.py index 43c5e4f..0b8cdb8 100644 --- a/meshchatx/src/backend/database/voicemails.py +++ b/meshchatx/src/backend/database/voicemails.py @@ -37,7 +37,16 @@ class VoicemailDAO: ), ) - def get_voicemails(self, limit=50, offset=0): + def get_voicemails(self, search=None, limit=50, offset=0): + if search: + return self.provider.fetchall( + """ + SELECT * FROM voicemails + WHERE remote_identity_name LIKE ? OR remote_identity_hash LIKE ? + ORDER BY timestamp DESC LIMIT ? OFFSET ? + """, + (f"%{search}%", f"%{search}%", limit, offset), + ) return self.provider.fetchall( "SELECT * FROM voicemails ORDER BY timestamp DESC LIMIT ? OFFSET ?", (limit, offset), diff --git a/meshchatx/src/backend/ringtone_manager.py b/meshchatx/src/backend/ringtone_manager.py index 95c09a5..af26bba 100644 --- a/meshchatx/src/backend/ringtone_manager.py +++ b/meshchatx/src/backend/ringtone_manager.py @@ -1,20 +1,22 @@ import os import shutil import subprocess + import RNS + class RingtoneManager: def __init__(self, config, storage_dir): self.config = config self.storage_dir = os.path.join(storage_dir, "ringtones") - + # Ensure directory exists os.makedirs(self.storage_dir, exist_ok=True) - + # Paths to executables self.ffmpeg_path = self._find_ffmpeg() self.has_ffmpeg = self.ffmpeg_path is not None - + if self.has_ffmpeg: RNS.log(f"Ringtone: Found ffmpeg at {self.ffmpeg_path}", RNS.LOG_DEBUG) else: diff --git a/meshchatx/src/backend/voicemail_manager.py b/meshchatx/src/backend/voicemail_manager.py index ffa25c3..5f8837e 100644 --- a/meshchatx/src/backend/voicemail_manager.py +++ b/meshchatx/src/backend/voicemail_manager.py @@ -325,6 +325,10 @@ class VoicemailManager: try: self.recording_sink = OpusFileSink(filepath) + # Ensure samplerate is set to avoid TypeError in LXST Opus codec + # which expects sink to have a valid samplerate attribute + self.recording_sink.samplerate = 48000 + # Connect the caller's audio source to our sink # active_call.audio_source is a LinkSource that feeds into receive_mixer # We want to record what we receive. diff --git a/meshchatx/src/frontend/components/call/CallOverlay.vue b/meshchatx/src/frontend/components/call/CallOverlay.vue index fcb1f9d..e62bc34 100644 --- a/meshchatx/src/frontend/components/call/CallOverlay.vue +++ b/meshchatx/src/frontend/components/call/CallOverlay.vue @@ -129,12 +129,12 @@ -
+
@@ -168,10 +168,10 @@ ? $t('call.decline_call') : $t('call.hangup_call') " - class="p-3 rounded-full bg-red-600 text-white hover:bg-red-700 shadow-lg shadow-red-600/30 transition-all duration-200" + class="p-2.5 rounded-full bg-red-600 text-white hover:bg-red-700 shadow-lg shadow-red-600/30 transition-all duration-200" @click="hangupCall" > - + @@ -179,10 +179,10 @@ v-if="activeCall.is_incoming && activeCall.status === 4" type="button" :title="$t('call.send_to_voicemail')" - class="p-3 rounded-full bg-blue-600 text-white hover:bg-blue-700 shadow-lg shadow-blue-600/30 transition-all duration-200" + class="p-2.5 rounded-full bg-blue-600 text-white hover:bg-blue-700 shadow-lg shadow-blue-600/30 transition-all duration-200" @click="sendToVoicemail" > - + @@ -190,10 +190,10 @@ v-if="activeCall.is_incoming && activeCall.status === 4" type="button" :title="$t('call.answer_call')" - class="p-3 rounded-full bg-green-600 text-white hover:bg-green-700 shadow-lg shadow-green-600/30 animate-bounce" + class="p-2.5 rounded-full bg-green-600 text-white hover:bg-green-700 shadow-lg shadow-green-600/30 animate-bounce" @click="answerCall" > - +
@@ -271,6 +271,7 @@ export default { default: false, }, }, + emits: ["hangup"], data() { return { isMinimized: false, diff --git a/meshchatx/src/frontend/components/call/CallPage.vue b/meshchatx/src/frontend/components/call/CallPage.vue index 2c8343d..96e1177 100644 --- a/meshchatx/src/frontend/components/call/CallPage.vue +++ b/meshchatx/src/frontend/components/call/CallPage.vue @@ -31,6 +31,17 @@ >{{ unreadVoicemailsCount }} + @@ -233,10 +244,10 @@ v-if="activeCall.is_incoming && activeCall.status === 4" :title="$t('call.send_to_voicemail')" type="button" - class="inline-flex items-center gap-x-2 rounded-2xl bg-blue-600 px-5 py-3 text-base font-bold text-white shadow-xl hover:bg-blue-500 transition-all duration-200" + class="inline-flex items-center gap-x-2 rounded-2xl bg-blue-600 px-4 py-2 text-sm font-bold text-white shadow-xl hover:bg-blue-500 transition-all duration-200" @click="sendToVoicemail" > - + {{ $t("call.send_to_voicemail") }} @@ -248,10 +259,10 @@ : $t('call.hangup_call') " type="button" - class="inline-flex items-center gap-x-2 rounded-2xl bg-red-600 px-5 py-3 text-base font-bold text-white shadow-xl hover:bg-red-500 transition-all duration-200" + class="inline-flex items-center gap-x-2 rounded-2xl bg-red-600 px-4 py-2 text-sm font-bold text-white shadow-xl hover:bg-red-500 transition-all duration-200" @click="hangupCall" > - + {{ activeCall.is_incoming && activeCall.status === 4 ? $t("call.decline") @@ -403,6 +414,21 @@
+
+
+ +
+ +
+
+
+
@@ -530,6 +556,114 @@
+ +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+

No Contacts

+

Add contacts to quickly call them.

+
+ +
+
+
    +
  • +
    +
    + +
    + +
    +
    +
    +
    +

    + {{ contact.name }} +

    +
    + + +
    +
    +
    + + {{ formatDestinationHash(contact.remote_identity_hash) }} + + +
    +
    +
    +
  • +
+
+
+
+
{ this.getHistory(); this.getVoicemails(); + this.getContacts(); }, 10000); // update elapsed time every second @@ -1204,13 +1350,78 @@ export default { }, async getVoicemails() { try { - const response = await window.axios.get("/api/v1/telephone/voicemails"); + const response = await window.axios.get("/api/v1/telephone/voicemails", { + params: { search: this.voicemailSearch }, + }); this.voicemails = response.data.voicemails; this.unreadVoicemailsCount = response.data.unread_count; } catch (e) { console.log(e); } }, + onVoicemailSearchInput() { + if (this.searchDebounceTimeout) clearTimeout(this.searchDebounceTimeout); + this.searchDebounceTimeout = setTimeout(() => { + this.getVoicemails(); + }, 300); + }, + async getContacts() { + try { + const response = await window.axios.get("/api/v1/telephone/contacts", { + params: { search: this.contactsSearch }, + }); + this.contacts = response.data; + } catch (e) { + console.log(e); + } + }, + onContactsSearchInput() { + if (this.searchDebounceTimeout) clearTimeout(this.searchDebounceTimeout); + this.searchDebounceTimeout = setTimeout(() => { + this.getContacts(); + }, 300); + }, + openAddContactModal() { + this.editingContact = null; + this.contactForm = { name: "", remote_identity_hash: "" }; + const name = prompt("Enter contact name:"); + if (!name) return; + const hash = prompt("Enter identity hash:"); + if (!hash) return; + this.saveContact({ name, remote_identity_hash: hash }); + }, + openEditContactModal(contact) { + this.editingContact = contact; + const name = prompt("Edit contact name:", contact.name); + if (!name) return; + const hash = prompt("Edit identity hash:", contact.remote_identity_hash); + if (!hash) return; + this.saveContact({ id: contact.id, name, remote_identity_hash: hash }); + }, + async saveContact(contact) { + try { + if (contact.id) { + await window.axios.patch(`/api/v1/telephone/contacts/${contact.id}`, contact); + ToastUtils.success("Contact updated"); + } else { + await window.axios.post("/api/v1/telephone/contacts", contact); + ToastUtils.success("Contact added"); + } + this.getContacts(); + } catch (e) { + ToastUtils.error(e.response?.data?.message || "Failed to save contact"); + } + }, + async deleteContact(contactId) { + if (!confirm("Are you sure you want to delete this contact?")) return; + try { + await window.axios.delete(`/api/v1/telephone/contacts/${contactId}`); + ToastUtils.success("Contact deleted"); + this.getContacts(); + } catch { + ToastUtils.error("Failed to delete contact"); + } + }, async generateGreeting() { this.isGeneratingGreeting = true; try { @@ -1259,7 +1470,9 @@ export default { }, async playVoicemail(voicemail) { if (this.playingVoicemailId === voicemail.id) { - this.audioPlayer.pause(); + if (this.audioPlayer) { + this.audioPlayer.pause(); + } this.playingVoicemailId = null; return; } @@ -1270,11 +1483,25 @@ export default { this.playingVoicemailId = voicemail.id; this.audioPlayer = new Audio(`/api/v1/telephone/voicemails/${voicemail.id}/audio`); - this.audioPlayer.play(); + + this.audioPlayer.addEventListener("error", (e) => { + console.error("Audio player error:", e); + ToastUtils.error(this.$t("call.failed_to_play_voicemail") || "Failed to load voicemail audio"); + this.playingVoicemailId = null; + this.audioPlayer = null; + }); + this.audioPlayer.onended = () => { this.playingVoicemailId = null; }; + try { + await this.audioPlayer.play(); + } catch (e) { + console.error("Audio play failed:", e); + this.playingVoicemailId = null; + } + // Mark as read if (!voicemail.is_read) { try { diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue index 84c0777..9f059d9 100644 --- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue +++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue @@ -108,6 +108,11 @@ + + + + + @@ -119,6 +124,64 @@
+ +
+
+
+

Share Contact

+ +
+
+
+
+ +
+ +
+
+
+
+ +
+
+
+
+
c.name.toLowerCase().includes(s) || c.remote_identity_hash.toLowerCase().includes(s) + ); + }, isMobile() { return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); }, @@ -1612,6 +1686,27 @@ export default { // do nothing if failed to delete message } }, + async openShareContactModal() { + try { + const response = await window.axios.get("/api/v1/telephone/contacts"); + this.contacts = response.data; + + if (this.contacts.length === 0) { + ToastUtils.info("No contacts found in telephone"); + return; + } + + this.isShareContactModalOpen = true; + } catch (e) { + console.error(e); + ToastUtils.error("Failed to load contacts"); + } + }, + async shareContact(contact) { + this.newMessageText = `Contact: ${contact.name} <${contact.remote_identity_hash}>`; + this.isShareContactModalOpen = false; + await this.sendMessage(); + }, async sendMessage() { // do nothing if can't send message if (!this.canSendMessage) { diff --git a/meshchatx/src/version.py b/meshchatx/src/version.py index 11a3bd4..12e5a76 100644 --- a/meshchatx/src/version.py +++ b/meshchatx/src/version.py @@ -3,4 +3,4 @@ Auto-generated helper so Python tooling and the Electron build share the same version string. """ -__version__ = '3.0.0' +__version__ = '3.1.0'