From 8bb38d3e516cac4240b15bd3cc881d1e55daacbb Mon Sep 17 00:00:00 2001 From: Sudo-Ivan Date: Mon, 5 Jan 2026 20:21:31 -0600 Subject: [PATCH] feat(maintenance): add API endpoint to clear LXMF icons and enhance backup configuration options --- meshchatx/meshchat.py | 73 ++++++++++++++++-- meshchatx/src/backend/bot_handler.py | 90 +++++++++++++++++++---- meshchatx/src/backend/config_manager.py | 1 + meshchatx/src/backend/database/misc.py | 9 +++ meshchatx/src/backend/identity_context.py | 10 +++ 5 files changed, 162 insertions(+), 21 deletions(-) diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py index ed27040..f3c98c7 100644 --- a/meshchatx/meshchat.py +++ b/meshchatx/meshchat.py @@ -4038,6 +4038,12 @@ class ReticulumMeshChat: self.database.misc.delete_archived_pages() return web.json_response({"message": "All archived pages cleared"}) + # maintenance - clear LXMF icons + @routes.delete("/api/v1/maintenance/lxmf-icons") + async def maintenance_clear_lxmf_icons(request): + self.database.misc.delete_all_user_icons() + return web.json_response({"message": "All LXMF icons cleared"}) + # maintenance - export messages @routes.get("/api/v1/maintenance/messages/export") async def maintenance_export_messages(request): @@ -8519,6 +8525,14 @@ class ReticulumMeshChat: int(data["archives_max_storage_gb"]), ) + if "backup_max_count" in data: + try: + value = int(data["backup_max_count"]) + except (TypeError, ValueError): + value = self.config.backup_max_count.default_value + value = max(1, min(value, 50)) + self.config.backup_max_count.set(value) + # update crawler settings if "crawler_enabled" in data: self.config.crawler_enabled.set(self._parse_bool(data["crawler_enabled"])) @@ -8591,6 +8605,14 @@ class ReticulumMeshChat: if "message_font_size" in data: self.config.message_font_size.set(int(data["message_font_size"])) + if "message_icon_size" in data: + try: + value = int(data["message_icon_size"]) + except (TypeError, ValueError): + value = self.config.message_icon_size.default_value + value = max(12, min(value, 96)) + self.config.message_icon_size.set(value) + # update desktop settings if "desktop_open_calls_in_separate_window" in data: self.config.desktop_open_calls_in_separate_window.set( @@ -9534,6 +9556,7 @@ class ReticulumMeshChat: "page_archiver_enabled": ctx.config.page_archiver_enabled.get(), "page_archiver_max_versions": ctx.config.page_archiver_max_versions.get(), "archives_max_storage_gb": ctx.config.archives_max_storage_gb.get(), + "backup_max_count": ctx.config.backup_max_count.get(), "crawler_enabled": ctx.config.crawler_enabled.get(), "crawler_max_retries": ctx.config.crawler_max_retries.get(), "crawler_retry_delay_seconds": ctx.config.crawler_retry_delay_seconds.get(), @@ -9569,6 +9592,7 @@ class ReticulumMeshChat: "banished_text": ctx.config.banished_text.get(), "banished_color": ctx.config.banished_color.get(), "message_font_size": ctx.config.message_font_size.get(), + "message_icon_size": ctx.config.message_icon_size.get(), "translator_enabled": ctx.config.translator_enabled.get(), "libretranslate_url": ctx.config.libretranslate_url.get(), "desktop_open_calls_in_separate_window": ctx.config.desktop_open_calls_in_separate_window.get(), @@ -10011,13 +10035,50 @@ class ReticulumMeshChat: icon_name = icon_appearance[0] foreground_colour = "#" + icon_appearance[1].hex() background_colour = "#" + icon_appearance[2].hex() - self.update_lxmf_user_icon( - lxmf_message.source_hash.hex(), - icon_name, - foreground_colour, - background_colour, - context=ctx, + + local_hash = ( + ctx.local_lxmf_destination.hexhash + if ctx.local_lxmf_destination + else None ) + source_hash = lxmf_message.source_hash.hex() + + # ignore our own icon and empty payloads to avoid overwriting peers with our appearance + if source_hash and local_hash and source_hash == local_hash: + pass + elif ( + not icon_name or not foreground_colour or not background_colour + ): + pass + else: + local_icon_name = ctx.config.lxmf_user_icon_name.get() + local_icon_fg = ( + ctx.config.lxmf_user_icon_foreground_colour.get() + ) + local_icon_bg = ( + ctx.config.lxmf_user_icon_background_colour.get() + ) + + # if incoming icon matches our own, skip storing and clear any mistaken stored copy + # for now, but this will need to be updated later if two users do have the same icon + # FIXME + if ( + local_icon_name + and local_icon_fg + and local_icon_bg + and icon_name == local_icon_name + and foreground_colour == local_icon_fg + and background_colour == local_icon_bg + ): + ctx.database.misc.delete_user_icon(source_hash) + else: + self.update_lxmf_user_icon( + source_hash, + icon_name, + foreground_colour, + background_colour, + context=ctx, + ) except Exception as e: print("failed to update lxmf user icon from lxmf message") print(e) diff --git a/meshchatx/src/backend/bot_handler.py b/meshchatx/src/backend/bot_handler.py index e5b99a1..ee1baf0 100644 --- a/meshchatx/src/backend/bot_handler.py +++ b/meshchatx/src/backend/bot_handler.py @@ -80,30 +80,66 @@ class BotHandler: logger.warning("Failed to restore bot %s: %s", entry.get("id"), exc) def get_status(self): - bots = [] - for bot_id, bot_info in self.running_bots.items(): - instance = bot_info["instance"] + bots: list[dict] = [] + + for entry in self.bots_state: + bot_id = entry.get("id") + template = entry.get("template_id") or entry.get("template") + name = entry.get("name") or "Unknown" + pid = entry.get("pid") + + running = False + if bot_id in self.running_bots: + running = True + elif pid: + running = self._is_pid_alive(pid) + + address_pretty = None + address_full = None + + # Try running instance first + instance = self.running_bots.get(bot_id, {}).get("instance") + if ( + instance + and getattr(instance, "bot", None) + and getattr(instance.bot, "local", None) + ): + try: + address_pretty = RNS.prettyhexrep(instance.bot.local.hash) + address_full = RNS.hexrep(instance.bot.local.hash, delimit=False) + except Exception: + pass + + # Fallback to identity file on disk + if address_full is None: + identity = self._load_identity_for_bot(bot_id) + if identity: + try: + destination = RNS.Destination(identity, "lxmf", "delivery") + address_full = destination.hash.hex() + address_pretty = RNS.prettyhexrep(destination.hash) + except Exception: + pass + bots.append( { "id": bot_id, - "template": bot_info["template"], - "name": instance.bot.config.name - if instance and instance.bot - else "Unknown", - "address": RNS.prettyhexrep(instance.bot.local.hash) - if instance and instance.bot and instance.bot.local - else "Unknown", - "full_address": RNS.hexrep(instance.bot.local.hash, delimit=False) - if instance and instance.bot and instance.bot.local - else None, + "template": template, + "template_id": template, + "name": name, + "address": address_pretty or "Unknown", + "full_address": address_full, + "running": running, + "pid": pid, + "storage_dir": entry.get("storage_dir"), }, ) return { "has_lxmfy": True, "detection_error": None, - "running_bots": bots, - "bots": self.bots_state, + "running_bots": [b for b in bots if b["running"]], + "bots": bots, } def start_bot(self, template_id, name=None, bot_id=None, storage_dir=None): @@ -274,8 +310,32 @@ class BotHandler: if os.path.exists(id_path_alt): return id_path_alt + # LXMFy may nest inside config/lxmf + id_path_lxmf = os.path.join(storage_dir, "config", "lxmf", "identity") + if os.path.exists(id_path_lxmf): + return id_path_lxmf + return None + def _load_identity_for_bot(self, bot_id): + identity_path = self.get_bot_identity_path(bot_id) + if not identity_path: + return None + try: + return RNS.Identity.from_file(identity_path) + except Exception: + return None + + @staticmethod + def _is_pid_alive(pid): + if not pid: + return False + try: + os.kill(pid, 0) + return True + except OSError: + return False + def stop_all(self): for bot_id in list(self.running_bots.keys()): self.stop_bot(bot_id) diff --git a/meshchatx/src/backend/config_manager.py b/meshchatx/src/backend/config_manager.py index cb4593f..b5803f3 100644 --- a/meshchatx/src/backend/config_manager.py +++ b/meshchatx/src/backend/config_manager.py @@ -270,6 +270,7 @@ class ConfigManager: "#dc2626", ) self.message_font_size = self.IntConfig(self, "message_font_size", 14) + self.message_icon_size = self.IntConfig(self, "message_icon_size", 28) # blackhole integration config self.blackhole_integration_enabled = self.BoolConfig( diff --git a/meshchatx/src/backend/database/misc.py b/meshchatx/src/backend/database/misc.py index 61f4008..9bc603b 100644 --- a/meshchatx/src/backend/database/misc.py +++ b/meshchatx/src/backend/database/misc.py @@ -99,6 +99,15 @@ class MiscDAO: tuple(destination_hashes), ) + def delete_user_icon(self, destination_hash): + self.provider.execute( + "DELETE FROM lxmf_user_icons WHERE destination_hash = ?", + (destination_hash,), + ) + + def delete_all_user_icons(self): + self.provider.execute("DELETE FROM lxmf_user_icons") + # Forwarding Rules def get_forwarding_rules(self, identity_hash=None, active_only=False): query = "SELECT * FROM lxmf_forwarding_rules WHERE 1=1" diff --git a/meshchatx/src/backend/identity_context.py b/meshchatx/src/backend/identity_context.py index 7e901a3..8d50086 100644 --- a/meshchatx/src/backend/identity_context.py +++ b/meshchatx/src/backend/identity_context.py @@ -206,6 +206,16 @@ class IdentityContext: lambda msg: self.app.on_lxmf_delivery(msg, context=self), ) + # Restore preferred propagation node on startup + try: + preferred_node = ( + self.config.lxmf_preferred_propagation_node_destination_hash.get() + ) + if preferred_node: + self.app.set_active_propagation_node(preferred_node, context=self) + except Exception: + pass + # 5. Initialize Handlers and Managers self.rncp_handler = RNCPHandler( reticulum_instance=getattr(self.app, "reticulum", None),