numerous improvements

This commit is contained in:
Sudo-Ivan 2026-01-05 11:47:35 -06:00
parent 5694c1ee67
commit fda9187e95
No known key found for this signature in database
GPG key ID: 1E0B37EE76428197
104 changed files with 4567 additions and 1070 deletions

File diff suppressed because it is too large Load diff

View file

@ -52,28 +52,37 @@ class AnnounceManager:
limit=None,
offset=0,
):
sql = "SELECT * FROM announces WHERE 1=1"
sql = """
SELECT a.*, c.custom_image as contact_image
FROM announces a
LEFT JOIN contacts c ON (
a.identity_hash = c.remote_identity_hash OR
a.destination_hash = c.lxmf_address OR
a.destination_hash = c.lxst_address
)
WHERE 1=1
"""
params = []
if aspect:
sql += " AND aspect = ?"
sql += " AND a.aspect = ?"
params.append(aspect)
if identity_hash:
sql += " AND identity_hash = ?"
sql += " AND a.identity_hash = ?"
params.append(identity_hash)
if destination_hash:
sql += " AND destination_hash = ?"
sql += " AND a.destination_hash = ?"
params.append(destination_hash)
if query:
like_term = f"%{query}%"
sql += " AND (destination_hash LIKE ? OR identity_hash LIKE ?)"
sql += " AND (a.destination_hash LIKE ? OR a.identity_hash LIKE ?)"
params.extend([like_term, like_term])
if blocked_identity_hashes:
placeholders = ", ".join(["?"] * len(blocked_identity_hashes))
sql += f" AND identity_hash NOT IN ({placeholders})"
sql += f" AND a.identity_hash NOT IN ({placeholders})"
params.extend(blocked_identity_hashes)
sql += " ORDER BY updated_at DESC"
sql += " ORDER BY a.updated_at DESC"
if limit is not None:
sql += " LIMIT ? OFFSET ?"

View file

@ -9,8 +9,7 @@ class AsyncUtils:
@staticmethod
def apply_asyncio_313_patch():
"""
Apply a patch for asyncio on Python 3.13 to avoid a bug in sendfile with SSL.
"""Apply a patch for asyncio on Python 3.13 to avoid a bug in sendfile with SSL.
See: https://github.com/python/cpython/issues/124448
And: https://github.com/aio-libs/aiohttp/issues/8863
"""
@ -23,14 +22,25 @@ class AsyncUtils:
original_sendfile = asyncio.base_events.BaseEventLoop.sendfile
async def patched_sendfile(
self, transport, file, offset=0, count=None, *, fallback=True
self,
transport,
file,
offset=0,
count=None,
*,
fallback=True,
):
if transport.get_extra_info("sslcontext"):
raise NotImplementedError(
"sendfile is broken on SSL transports in Python 3.13"
"sendfile is broken on SSL transports in Python 3.13",
)
return await original_sendfile(
self, transport, file, offset, count, fallback=fallback
self,
transport,
file,
offset,
count,
fallback=fallback,
)
asyncio.base_events.BaseEventLoop.sendfile = patched_sendfile

View file

@ -0,0 +1,279 @@
import json
import logging
import os
import shutil
import subprocess
import sys
import time
import uuid
import RNS
logger = logging.getLogger("meshchatx.bots")
class BotHandler:
def __init__(self, identity_path, config_manager=None):
self.identity_path = os.path.abspath(identity_path)
self.config_manager = config_manager
self.bots_dir = os.path.join(self.identity_path, "bots")
os.makedirs(self.bots_dir, exist_ok=True)
self.running_bots = {}
self.state_file = os.path.join(self.bots_dir, "bots_state.json")
self.bots_state: list[dict] = []
self._load_state()
self.runner_path = os.path.join(
os.path.dirname(__file__),
"bot_process.py",
)
def _load_state(self):
try:
with open(self.state_file, encoding="utf-8") as f:
self.bots_state = json.load(f)
# Ensure all storage paths are absolute
for entry in self.bots_state:
if "storage_dir" in entry:
entry["storage_dir"] = os.path.abspath(entry["storage_dir"])
except FileNotFoundError:
self.bots_state = []
except Exception:
self.bots_state = []
def _save_state(self):
try:
with open(self.state_file, "w", encoding="utf-8") as f:
json.dump(self.bots_state, f, indent=2)
except Exception:
pass
def get_available_templates(self):
return [
{
"id": "echo",
"name": "Echo Bot",
"description": "Repeats any message it receives.",
},
{
"id": "note",
"name": "Note Bot",
"description": "Store and retrieve notes using JSON storage.",
},
{
"id": "reminder",
"name": "Reminder Bot",
"description": "Set and receive reminders using SQLite storage.",
},
]
def restore_enabled_bots(self):
for entry in list(self.bots_state):
if entry.get("enabled"):
try:
self.start_bot(
template_id=entry["template_id"],
name=entry["name"],
bot_id=entry["id"],
storage_dir=entry["storage_dir"],
)
except Exception as exc:
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.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,
},
)
return {
"has_lxmfy": True,
"detection_error": None,
"running_bots": bots,
"bots": self.bots_state,
}
def start_bot(self, template_id, name=None, bot_id=None, storage_dir=None):
# Reuse existing entry or create new
entry = None
if bot_id:
for e in self.bots_state:
if e.get("id") == bot_id:
entry = e
break
if entry is None:
bot_id = bot_id or uuid.uuid4().hex
bot_storage_dir = storage_dir or os.path.join(self.bots_dir, bot_id)
bot_storage_dir = os.path.abspath(bot_storage_dir)
entry = {
"id": bot_id,
"template_id": template_id,
"name": name or f"{template_id.title()} Bot",
"storage_dir": bot_storage_dir,
"enabled": True,
"pid": None,
}
self.bots_state.append(entry)
else:
bot_storage_dir = entry["storage_dir"]
entry["template_id"] = template_id
entry["name"] = name or entry.get("name") or f"{template_id.title()} Bot"
entry["enabled"] = True
os.makedirs(bot_storage_dir, exist_ok=True)
cmd = [
sys.executable,
self.runner_path,
"--template",
template_id,
"--name",
entry["name"],
"--storage",
bot_storage_dir,
]
proc = subprocess.Popen(cmd, cwd=bot_storage_dir) # noqa: S603
entry["pid"] = proc.pid
self._save_state()
self.running_bots[bot_id] = {
"instance": None,
"thread": None,
"stop_event": None,
"template": template_id,
"pid": proc.pid,
}
logger.info(f"Started bot {bot_id} (template: {template_id}) pid={proc.pid}")
return bot_id
def stop_bot(self, bot_id):
entry = None
for e in self.bots_state:
if e.get("id") == bot_id:
entry = e
break
if entry is None:
return False
pid = entry.get("pid")
if pid:
try:
if sys.platform.startswith("win"):
subprocess.run(
["taskkill", "/PID", str(pid), "/T", "/F"],
check=False,
timeout=5,
)
else:
os.kill(pid, 15)
# brief wait
time.sleep(0.5)
# optional force kill if still alive
try:
os.kill(pid, 0)
os.kill(pid, 9)
except OSError:
pass
except Exception as exc:
logger.warning(
"Failed to terminate bot %s pid %s: %s",
bot_id,
pid,
exc,
)
entry["pid"] = None
entry["enabled"] = False
self._save_state()
if bot_id in self.running_bots:
del self.running_bots[bot_id]
logger.info("Stopped bot %s", bot_id)
return True
def restart_bot(self, bot_id):
entry = None
for e in self.bots_state:
if e.get("id") == bot_id:
entry = e
break
if entry is None:
raise ValueError(f"Unknown bot: {bot_id}")
self.stop_bot(bot_id)
return self.start_bot(
template_id=entry["template_id"],
name=entry["name"],
bot_id=bot_id,
storage_dir=entry["storage_dir"],
)
def delete_bot(self, bot_id):
# Stop it first
self.stop_bot(bot_id)
# Remove from state
entry = None
for i, e in enumerate(self.bots_state):
if e.get("id") == bot_id:
entry = e
del self.bots_state[i]
break
if entry:
# Delete storage dir
storage_dir = entry.get("storage_dir")
if storage_dir and os.path.exists(storage_dir):
try:
shutil.rmtree(storage_dir)
except Exception as exc:
logger.warning(
"Failed to delete storage dir for bot %s: %s", bot_id, exc
)
self._save_state()
logger.info("Deleted bot %s", bot_id)
return True
return False
def get_bot_identity_path(self, bot_id):
entry = None
for e in self.bots_state:
if e.get("id") == bot_id:
entry = e
break
if not entry:
return None
storage_dir = entry.get("storage_dir")
if not storage_dir:
return None
# LXMFy stores identity in the 'config' subdirectory by default
id_path = os.path.join(storage_dir, "config", "identity")
if os.path.exists(id_path):
return id_path
# Fallback to direct identity file if it was moved or configured differently
id_path_alt = os.path.join(storage_dir, "identity")
if os.path.exists(id_path_alt):
return id_path_alt
return None
def stop_all(self):
for bot_id in list(self.running_bots.keys()):
self.stop_bot(bot_id)

View file

@ -0,0 +1,45 @@
import argparse
import os
from meshchatx.src.backend.bot_templates import (
EchoBotTemplate,
NoteBotTemplate,
ReminderBotTemplate,
)
TEMPLATE_MAP = {
"echo": EchoBotTemplate,
"note": NoteBotTemplate,
"reminder": ReminderBotTemplate,
}
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--template", required=True, choices=TEMPLATE_MAP.keys())
parser.add_argument("--name", required=True)
parser.add_argument("--storage", required=True)
args = parser.parse_args()
os.makedirs(args.storage, exist_ok=True)
os.chdir(args.storage)
BotCls = TEMPLATE_MAP[args.template]
# LXMFy hardcodes its config directory to os.path.join(os.getcwd(), 'config').
# By chdir'ing into args.storage, we ensure 'config' and data are kept within that folder.
bot_instance = BotCls(name=args.name, storage_path=args.storage, test_mode=False)
# Optional immediate announce for reachability
try:
if hasattr(bot_instance.bot, "announce_enabled"):
bot_instance.bot.announce_enabled = True
if hasattr(bot_instance.bot, "_announce"):
bot_instance.bot._announce()
except Exception:
pass
bot_instance.run()
if __name__ == "__main__":
main()

View file

@ -0,0 +1,265 @@
import re
import time
from datetime import datetime, timedelta
from lxmfy import IconAppearance, LXMFBot, pack_icon_appearance_field
HAS_LXMFY = True
class StoppableBot:
def __init__(self):
self._stop_event = None
def set_stop_event(self, stop_event):
self._stop_event = stop_event
def should_stop(self):
return self._stop_event and self._stop_event.is_set()
class EchoBotTemplate(StoppableBot):
def __init__(self, name="Echo Bot", storage_path=None, test_mode=False):
super().__init__()
self.bot = LXMFBot(
name=name,
announce=600,
command_prefix="",
first_message_enabled=True,
test_mode=test_mode,
storage_path=storage_path,
)
self.setup_commands()
self.setup_message_handlers()
icon_data = IconAppearance(
icon_name="forum",
fg_color=b"\xad\xd8\xe6",
bg_color=b"\x3b\x59\x98",
)
self.icon_lxmf_field = pack_icon_appearance_field(icon_data)
def setup_message_handlers(self):
@self.bot.on_message()
def echo_non_command_messages(sender, message):
if self.should_stop():
return True
content = message.content.decode("utf-8").strip()
if not content:
return False
command_name = content.split()[0]
if command_name in self.bot.commands:
return False
self.bot.send(
sender,
content,
lxmf_fields=self.icon_lxmf_field,
)
return False
def setup_commands(self):
@self.bot.command(name="echo", description="Echo back your message")
def echo(ctx):
if self.should_stop():
return
if ctx.args:
ctx.reply(" ".join(ctx.args), lxmf_fields=self.icon_lxmf_field)
else:
ctx.reply("Usage: echo <message>", lxmf_fields=self.icon_lxmf_field)
@self.bot.on_first_message()
def welcome(sender, message):
if self.should_stop():
return True
content = message.content.decode("utf-8").strip()
self.bot.send(
sender,
f"Hi! I'm an echo bot, You said: {content}\n\n"
"Try: echo <message> to make me repeat things!",
lxmf_fields=self.icon_lxmf_field,
)
return True
def run(self):
self.bot.scheduler.start()
try:
while not self.should_stop():
for _ in range(self.bot.queue.qsize()):
lxm = self.bot.queue.get()
if self.bot.router:
self.bot.router.handle_outbound(lxm)
time.sleep(1)
finally:
self.bot.cleanup()
class NoteBotTemplate(StoppableBot):
def __init__(self, name="Note Bot", storage_path=None, test_mode=False):
super().__init__()
self.bot = LXMFBot(
name=name,
announce=600,
command_prefix="/",
storage_type="json",
storage_path=storage_path or "data/notes",
test_mode=test_mode,
)
self.setup_commands()
def setup_commands(self):
@self.bot.command(name="note", description="Save a note")
def save_note(ctx):
if self.should_stop():
return
if not ctx.args:
ctx.reply("Usage: /note <your note>")
return
note = {
"text": " ".join(ctx.args),
"timestamp": datetime.now().isoformat(),
"tags": [w[1:] for w in ctx.args if w.startswith("#")],
}
notes = self.bot.storage.get(f"notes:{ctx.sender}", [])
notes.append(note)
self.bot.storage.set(f"notes:{ctx.sender}", notes)
ctx.reply("Note saved!")
@self.bot.command(name="notes", description="List your notes")
def list_notes(ctx):
if self.should_stop():
return
notes = self.bot.storage.get(f"notes:{ctx.sender}", [])
if not notes:
ctx.reply("You haven't saved any notes yet!")
return
if not ctx.args:
response = "Your Notes:\n"
for i, note in enumerate(notes[-10:], 1):
tags = (
" ".join(f"#{tag}" for tag in note["tags"])
if note["tags"]
else ""
)
response += f"{i}. {note['text']} {tags}\n"
if len(notes) > 10:
response += f"\nShowing last 10 of {len(notes)} notes. Use /notes all to see all."
ctx.reply(response)
elif ctx.args[0] == "all":
response = "All Your Notes:\n"
for i, note in enumerate(notes, 1):
tags = (
" ".join(f"#{tag}" for tag in note["tags"])
if note["tags"]
else ""
)
response += f"{i}. {note['text']} {tags}\n"
ctx.reply(response)
def run(self):
self.bot.scheduler.start()
try:
while not self.should_stop():
for _ in range(self.bot.queue.qsize()):
lxm = self.bot.queue.get()
if self.bot.router:
self.bot.router.handle_outbound(lxm)
time.sleep(1)
finally:
self.bot.cleanup()
class ReminderBotTemplate(StoppableBot):
def __init__(self, name="Reminder Bot", storage_path=None, test_mode=False):
super().__init__()
self.bot = LXMFBot(
name=name,
announce=600,
command_prefix="/",
storage_type="sqlite",
storage_path=storage_path or "data/reminders.db",
test_mode=test_mode,
)
self.setup_commands()
self.bot.scheduler.add_task(
"check_reminders",
self._check_reminders,
"*/1 * * * *",
)
def setup_commands(self):
@self.bot.command(name="remind", description="Set a reminder")
def remind(ctx):
if self.should_stop():
return
if not ctx.args or len(ctx.args) < 2:
ctx.reply(
"Usage: /remind <time> <message>\nExample: /remind 1h30m Buy groceries",
)
return
time_str = ctx.args[0].lower()
message = " ".join(ctx.args[1:])
total_minutes = 0
time_parts = re.findall(r"(\d+)([dhm])", time_str)
for value, unit in time_parts:
if unit == "d":
total_minutes += int(value) * 24 * 60
elif unit == "h":
total_minutes += int(value) * 60
elif unit == "m":
total_minutes += int(value)
if total_minutes == 0:
ctx.reply("Invalid time format. Use combinations of d, h, m")
return
remind_time = datetime.now() + timedelta(minutes=total_minutes)
reminder = {
"user": ctx.sender,
"message": message,
"time": remind_time.timestamp(),
"created": time.time(),
}
reminders = self.bot.storage.get("reminders", [])
reminders.append(reminder)
self.bot.storage.set("reminders", reminders)
ctx.reply(
f"I'll remind you about '{message}' at {remind_time.strftime('%Y-%m-%d %H:%M:%S')}",
)
def _check_reminders(self):
if self.should_stop():
return
reminders = self.bot.storage.get("reminders", [])
current_time = time.time()
due_reminders = [r for r in reminders if r["time"] <= current_time]
remaining = [r for r in reminders if r["time"] > current_time]
for reminder in due_reminders:
self.bot.send(reminder["user"], f"Reminder: {reminder['message']}")
if due_reminders:
self.bot.storage.set("reminders", remaining)
def run(self):
self.bot.scheduler.start()
try:
while not self.should_stop():
for _ in range(self.bot.queue.qsize()):
lxm = self.bot.queue.get()
if self.bot.router:
self.bot.router.handle_outbound(lxm)
time.sleep(1)
finally:
self.bot.cleanup()

View file

@ -1,6 +1,6 @@
import asyncio
import time
from typing import List, Dict, Any
from typing import Any
class CommunityInterfacesManager:
@ -67,7 +67,8 @@ class CommunityInterfacesManager:
# but that requires Reticulum to be running with a configured interface to that target.
# For "suggested" interfaces, we just check if they are reachable.
reader, writer = await asyncio.wait_for(
asyncio.open_connection(host, port), timeout=3.0
asyncio.open_connection(host, port),
timeout=3.0,
)
writer.close()
await writer.wait_closed()
@ -90,7 +91,7 @@ class CommunityInterfacesManager:
}
self.last_check = time.time()
async def get_interfaces(self) -> List[Dict[str, Any]]:
async def get_interfaces(self) -> list[dict[str, Any]]:
# If cache is old or empty, update it
if time.time() - self.last_check > self.check_interval or not self.status_cache:
# We don't want to block the request, so we could do this in background
@ -100,14 +101,15 @@ class CommunityInterfacesManager:
results = []
for iface in self.interfaces:
status = self.status_cache.get(
iface["name"], {"online": False, "last_check": 0}
iface["name"],
{"online": False, "last_check": 0},
)
results.append(
{
**iface,
"online": status["online"],
"last_check": status["last_check"],
}
},
)
# Sort so online ones are first

View file

@ -60,6 +60,8 @@ class ConfigManager:
"lxmf_preferred_propagation_node_last_synced_at",
None,
)
self.lxmf_address_hash = self.StringConfig(self, "lxmf_address_hash", None)
self.lxst_address_hash = self.StringConfig(self, "lxst_address_hash", None)
self.lxmf_local_propagation_node_enabled = self.BoolConfig(
self,
"lxmf_local_propagation_node_enabled",
@ -119,7 +121,9 @@ class ConfigManager:
False,
)
self.gitea_base_url = self.StringConfig(
self, "gitea_base_url", "https://git.quad4.io"
self,
"gitea_base_url",
"https://git.quad4.io",
)
self.docs_download_urls = self.StringConfig(
self,
@ -159,7 +163,9 @@ class ConfigManager:
self.voicemail_tts_speed = self.IntConfig(self, "voicemail_tts_speed", 130)
self.voicemail_tts_pitch = self.IntConfig(self, "voicemail_tts_pitch", 45)
self.voicemail_tts_voice = self.StringConfig(
self, "voicemail_tts_voice", "en-us+f3"
self,
"voicemail_tts_voice",
"en-us+f3",
)
self.voicemail_tts_word_gap = self.IntConfig(self, "voicemail_tts_word_gap", 5)

View file

@ -130,7 +130,7 @@ class Database:
self._checkpoint_wal()
except Exception as e:
print(
f"Warning: WAL checkpoint during vacuum failed (non-critical): {e}"
f"Warning: WAL checkpoint during vacuum failed (non-critical): {e}",
)
self.execute_sql("VACUUM")
@ -226,7 +226,7 @@ class Database:
os.makedirs(snapshot_dir, exist_ok=True)
# Ensure name is safe for filesystem
safe_name = "".join(
[c for c in name if c.isalnum() or c in (" ", ".", "-", "_")]
[c for c in name if c.isalnum() or c in (" ", ".", "-", "_")],
).strip()
if not safe_name:
safe_name = "unnamed_snapshot"
@ -251,9 +251,10 @@ class Database:
"path": full_path,
"size": stats.st_size,
"created_at": datetime.fromtimestamp(
stats.st_mtime, UTC
stats.st_mtime,
UTC,
).isoformat(),
}
},
)
return sorted(snapshots, key=lambda x: x["created_at"], reverse=True)

View file

@ -6,19 +6,34 @@ class ContactsDAO:
self.provider = provider
def add_contact(
self, name, remote_identity_hash, preferred_ringtone_id=None, custom_image=None
self,
name,
remote_identity_hash,
lxmf_address=None,
lxst_address=None,
preferred_ringtone_id=None,
custom_image=None,
):
self.provider.execute(
"""
INSERT INTO contacts (name, remote_identity_hash, preferred_ringtone_id, custom_image)
VALUES (?, ?, ?, ?)
INSERT INTO contacts (name, remote_identity_hash, lxmf_address, lxst_address, preferred_ringtone_id, custom_image)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(remote_identity_hash) DO UPDATE SET
name = EXCLUDED.name,
lxmf_address = COALESCE(EXCLUDED.lxmf_address, contacts.lxmf_address),
lxst_address = COALESCE(EXCLUDED.lxst_address, contacts.lxst_address),
preferred_ringtone_id = EXCLUDED.preferred_ringtone_id,
custom_image = EXCLUDED.custom_image,
updated_at = CURRENT_TIMESTAMP
""",
(name, remote_identity_hash, preferred_ringtone_id, custom_image),
(
name,
remote_identity_hash,
lxmf_address,
lxst_address,
preferred_ringtone_id,
custom_image,
),
)
def get_contacts(self, search=None, limit=100, offset=0):
@ -26,10 +41,17 @@ class ContactsDAO:
return self.provider.fetchall(
"""
SELECT * FROM contacts
WHERE name LIKE ? OR remote_identity_hash LIKE ?
WHERE name LIKE ? OR remote_identity_hash LIKE ? OR lxmf_address LIKE ? OR lxst_address LIKE ?
ORDER BY name ASC LIMIT ? OFFSET ?
""",
(f"%{search}%", f"%{search}%", limit, offset),
(
f"%{search}%",
f"%{search}%",
f"%{search}%",
f"%{search}%",
limit,
offset,
),
)
return self.provider.fetchall(
"SELECT * FROM contacts ORDER BY name ASC LIMIT ? OFFSET ?",
@ -47,6 +69,8 @@ class ContactsDAO:
contact_id,
name=None,
remote_identity_hash=None,
lxmf_address=None,
lxst_address=None,
preferred_ringtone_id=None,
custom_image=None,
clear_image=False,
@ -60,6 +84,12 @@ class ContactsDAO:
if remote_identity_hash is not None:
updates.append("remote_identity_hash = ?")
params.append(remote_identity_hash)
if lxmf_address is not None:
updates.append("lxmf_address = ?")
params.append(lxmf_address)
if lxst_address is not None:
updates.append("lxst_address = ?")
params.append(lxst_address)
if preferred_ringtone_id is not None:
updates.append("preferred_ringtone_id = ?")
params.append(preferred_ringtone_id)
@ -82,6 +112,6 @@ class ContactsDAO:
def get_contact_by_identity_hash(self, remote_identity_hash):
return self.provider.fetchone(
"SELECT * FROM contacts WHERE remote_identity_hash = ?",
(remote_identity_hash,),
"SELECT * FROM contacts WHERE remote_identity_hash = ? OR lxmf_address = ? OR lxst_address = ?",
(remote_identity_hash, remote_identity_hash, remote_identity_hash),
)

View file

@ -1,4 +1,5 @@
from datetime import UTC, datetime
from .provider import DatabaseProvider
@ -24,7 +25,13 @@ class DebugLogsDAO:
)
def get_logs(
self, limit=100, offset=0, search=None, level=None, module=None, is_anomaly=None
self,
limit=100,
offset=0,
search=None,
level=None,
module=None,
is_anomaly=None,
):
sql = "SELECT * FROM debug_logs WHERE 1=1"
params = []
@ -83,7 +90,8 @@ class DebugLogsDAO:
if row:
cutoff_ts = row["timestamp"]
self.provider.execute(
"DELETE FROM debug_logs WHERE timestamp < ?", (cutoff_ts,)
"DELETE FROM debug_logs WHERE timestamp < ?",
(cutoff_ts,),
)
def get_anomalies(self, limit=50):

View file

@ -1,4 +1,5 @@
from datetime import UTC, datetime
from .provider import DatabaseProvider

View file

@ -69,13 +69,12 @@ class DatabaseProvider:
self.connection.commit()
elif commit is False:
pass
else:
# Default behavior: if we're in a manual transaction, don't commit automatically
if not self.connection.in_transaction:
# In autocommit mode, non-DML statements don't start transactions.
# DML statements might if they are part of a BEGIN block.
# Actually, in isolation_level=None, NOTHING starts a transaction unless we say BEGIN.
pass
# Default behavior: if we're in a manual transaction, don't commit automatically
elif not self.connection.in_transaction:
# In autocommit mode, non-DML statements don't start transactions.
# DML statements might if they are part of a BEGIN block.
# Actually, in isolation_level=None, NOTHING starts a transaction unless we say BEGIN.
pass
return cursor
def begin(self):

View file

@ -2,7 +2,7 @@ from .provider import DatabaseProvider
class DatabaseSchema:
LATEST_VERSION = 34
LATEST_VERSION = 35
def __init__(self, provider: DatabaseProvider):
self.provider = provider
@ -63,21 +63,20 @@ class DatabaseSchema:
# Use the connection directly to avoid any middle-ware issues
res = self._safe_execute(
f"ALTER TABLE {table_name} ADD COLUMN {column_name} {stmt_type}"
f"ALTER TABLE {table_name} ADD COLUMN {column_name} {stmt_type}",
)
return res is not None
except Exception as e:
# Log but don't crash, we might be able to continue
print(
f"Unexpected error adding column {column_name} to {table_name}: {e}"
f"Unexpected error adding column {column_name} to {table_name}: {e}",
)
return False
return True
return True
def _sync_table_columns(self, table_name, create_sql):
"""
Parses a CREATE TABLE statement and ensures all columns exist in the actual table.
"""Parses a CREATE TABLE statement and ensures all columns exist in the actual table.
This is a robust way to handle legacy tables that are missing columns.
"""
# Find the first '(' and the last ')'
@ -111,7 +110,7 @@ class DatabaseSchema:
definition = definition.strip()
# Skip table-level constraints
if not definition or definition.upper().startswith(
("PRIMARY KEY", "FOREIGN KEY", "UNIQUE", "CHECK")
("PRIMARY KEY", "FOREIGN KEY", "UNIQUE", "CHECK"),
):
continue
@ -365,6 +364,8 @@ class DatabaseSchema:
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT,
remote_identity_hash TEXT UNIQUE,
lxmf_address TEXT,
lxst_address TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
@ -923,6 +924,15 @@ class DatabaseSchema:
"ALTER TABLE crawl_tasks ADD COLUMN updated_at DATETIME DEFAULT CURRENT_TIMESTAMP",
)
if current_version < 35:
# Add lxmf_address and lxst_address to contacts
self._safe_execute(
"ALTER TABLE contacts ADD COLUMN lxmf_address TEXT DEFAULT NULL",
)
self._safe_execute(
"ALTER TABLE contacts ADD COLUMN lxst_address TEXT DEFAULT NULL",
)
# Update version in config
self._safe_execute(
"""

View file

@ -1,13 +1,14 @@
import html
import io
import logging
import os
import re
import shutil
import threading
import zipfile
import io
import html
import requests
from meshchatx.src.backend.markdown_renderer import MarkdownRenderer
@ -46,12 +47,13 @@ class DocsManager:
self._update_current_link()
except OSError as e:
logging.error(f"Failed to create documentation directories: {e}")
logging.exception(f"Failed to create documentation directories: {e}")
self.last_error = str(e)
# Initial population of MeshChatX docs
if os.path.exists(self.meshchatx_docs_dir) and os.access(
self.meshchatx_docs_dir, os.W_OK
self.meshchatx_docs_dir,
os.W_OK,
):
self.populate_meshchatx_docs()
@ -115,7 +117,7 @@ class DocsManager:
version_file = os.path.join(self.docs_dir, ".version")
if os.path.exists(version_file):
try:
with open(version_file, "r") as f:
with open(version_file) as f:
return f.read().strip()
except OSError:
pass
@ -142,7 +144,7 @@ class DocsManager:
# Project root is 3 levels up
this_dir = os.path.dirname(os.path.abspath(__file__))
search_paths.append(
os.path.abspath(os.path.join(this_dir, "..", "..", "..", "docs"))
os.path.abspath(os.path.join(this_dir, "..", "..", "..", "docs")),
)
src_docs = None
@ -163,13 +165,13 @@ class DocsManager:
# Only copy if source and destination are different
if os.path.abspath(src_path) != os.path.abspath(
dest_path
dest_path,
) and os.access(self.meshchatx_docs_dir, os.W_OK):
shutil.copy2(src_path, dest_path)
# Also pre-render to HTML for easy sharing/viewing
try:
with open(src_path, "r", encoding="utf-8") as f:
with open(src_path, encoding="utf-8") as f:
content = f.read()
html_content = MarkdownRenderer.render(content)
@ -199,9 +201,9 @@ class DocsManager:
) as f:
f.write(full_html)
except Exception as e:
logging.error(f"Failed to render {file} to HTML: {e}")
logging.exception(f"Failed to render {file} to HTML: {e}")
except Exception as e:
logging.error(f"Failed to populate MeshChatX docs: {e}")
logging.exception(f"Failed to populate MeshChatX docs: {e}")
def get_status(self):
return {
@ -228,15 +230,15 @@ class DocsManager:
if not os.path.exists(self.meshchatx_docs_dir):
return docs
for file in os.listdir(self.meshchatx_docs_dir):
if file.endswith((".md", ".txt")):
docs.append(
{
"name": file,
"path": file,
"type": "markdown" if file.endswith(".md") else "text",
}
)
docs.extend(
{
"name": file,
"path": file,
"type": "markdown" if file.endswith(".md") else "text",
}
for file in os.listdir(self.meshchatx_docs_dir)
if file.endswith((".md", ".txt"))
)
return sorted(docs, key=lambda x: x["name"])
def get_doc_content(self, path):
@ -244,7 +246,7 @@ class DocsManager:
if not os.path.exists(full_path):
return None
with open(full_path, "r", encoding="utf-8", errors="ignore") as f:
with open(full_path, encoding="utf-8", errors="ignore") as f:
content = f.read()
if path.endswith(".md"):
@ -253,12 +255,11 @@ class DocsManager:
"html": MarkdownRenderer.render(content),
"type": "markdown",
}
else:
return {
"content": content,
"html": f"<pre class='whitespace-pre-wrap font-mono'>{html.escape(content)}</pre>",
"type": "text",
}
return {
"content": content,
"html": f"<pre class='whitespace-pre-wrap font-mono'>{html.escape(content)}</pre>",
"type": "text",
}
def export_docs(self):
"""Creates a zip of all docs and returns the bytes."""
@ -269,7 +270,8 @@ class DocsManager:
for file in files:
file_path = os.path.join(root, file)
rel_path = os.path.join(
"reticulum-docs", os.path.relpath(file_path, self.docs_dir)
"reticulum-docs",
os.path.relpath(file_path, self.docs_dir),
)
zip_file.write(file_path, rel_path)
@ -300,7 +302,9 @@ class DocsManager:
file_path = os.path.join(self.meshchatx_docs_dir, file)
try:
with open(
file_path, "r", encoding="utf-8", errors="ignore"
file_path,
encoding="utf-8",
errors="ignore",
) as f:
content = f.read()
if query in content.lower():
@ -320,10 +324,10 @@ class DocsManager:
"path": f"/meshchatx-docs/{file}",
"snippet": snippet,
"source": "MeshChatX",
}
},
)
except Exception as e:
logging.error(f"Error searching MeshChatX doc {file}: {e}")
logging.exception(f"Error searching MeshChatX doc {file}: {e}")
# 2. Search Reticulum Docs
if self.has_docs():
@ -405,7 +409,7 @@ class DocsManager:
"path": f"/reticulum-docs/{rel_path}",
"snippet": snippet,
"source": "Reticulum",
}
},
)
if len(results) >= 25: # Limit results
@ -469,7 +473,7 @@ class DocsManager:
downloaded_size += len(chunk)
if total_size > 0:
self.download_progress = int(
(downloaded_size / total_size) * 90
(downloaded_size / total_size) * 90,
)
# Extract

View file

@ -1,28 +1,31 @@
import os
import asyncio
import os
import threading
import RNS
from meshchatx.src.backend.database import Database
from meshchatx.src.backend.integrity_manager import IntegrityManager
from meshchatx.src.backend.config_manager import ConfigManager
from meshchatx.src.backend.message_handler import MessageHandler
from meshchatx.src.backend.announce_handler import AnnounceHandler
from meshchatx.src.backend.announce_manager import AnnounceManager
from meshchatx.src.backend.archiver_manager import ArchiverManager
from meshchatx.src.backend.map_manager import MapManager
from meshchatx.src.backend.bot_handler import BotHandler
from meshchatx.src.backend.community_interfaces import CommunityInterfacesManager
from meshchatx.src.backend.config_manager import ConfigManager
from meshchatx.src.backend.database import Database
from meshchatx.src.backend.docs_manager import DocsManager
from meshchatx.src.backend.forwarding_manager import ForwardingManager
from meshchatx.src.backend.integrity_manager import IntegrityManager
from meshchatx.src.backend.map_manager import MapManager
from meshchatx.src.backend.meshchat_utils import create_lxmf_router
from meshchatx.src.backend.message_handler import MessageHandler
from meshchatx.src.backend.nomadnet_utils import NomadNetworkManager
from meshchatx.src.backend.telephone_manager import TelephoneManager
from meshchatx.src.backend.voicemail_manager import VoicemailManager
from meshchatx.src.backend.ringtone_manager import RingtoneManager
from meshchatx.src.backend.rncp_handler import RNCPHandler
from meshchatx.src.backend.rnstatus_handler import RNStatusHandler
from meshchatx.src.backend.rnpath_handler import RNPathHandler
from meshchatx.src.backend.rnprobe_handler import RNProbeHandler
from meshchatx.src.backend.rnstatus_handler import RNStatusHandler
from meshchatx.src.backend.telephone_manager import TelephoneManager
from meshchatx.src.backend.translator_handler import TranslatorHandler
from meshchatx.src.backend.forwarding_manager import ForwardingManager
from meshchatx.src.backend.meshchat_utils import create_lxmf_router
from meshchatx.src.backend.announce_handler import AnnounceHandler
from meshchatx.src.backend.community_interfaces import CommunityInterfacesManager
from meshchatx.src.backend.voicemail_manager import VoicemailManager
class IdentityContext:
@ -71,12 +74,15 @@ class IdentityContext:
self.rnstatus_handler = None
self.rnprobe_handler = None
self.translator_handler = None
self.bot_handler = None
self.forwarding_manager = None
self.community_interfaces_manager = None
self.local_lxmf_destination = None
self.announce_handlers = []
self.integrity_manager = IntegrityManager(
self.storage_path, self.database_path, self.identity_hash
self.storage_path,
self.database_path,
self.identity_hash,
)
self.running = False
@ -102,7 +108,7 @@ class IdentityContext:
is_ok, issues = self.integrity_manager.check_integrity()
if not is_ok:
print(
f"INTEGRITY WARNING for {self.identity_hash}: {', '.join(issues)}"
f"INTEGRITY WARNING for {self.identity_hash}: {', '.join(issues)}",
)
if not hasattr(self.app, "integrity_issues"):
self.app.integrity_issues = []
@ -120,7 +126,7 @@ class IdentityContext:
if not self.app.auto_recover and not getattr(self.app, "emergency", False):
raise
print(
f"Database initialization failed for {self.identity_hash}, attempting recovery: {exc}"
f"Database initialization failed for {self.identity_hash}, attempting recovery: {exc}",
)
if not getattr(self.app, "emergency", False):
self.app._run_startup_auto_recovery()
@ -151,8 +157,8 @@ class IdentityContext:
self.app.get_public_path(),
project_root=os.path.dirname(
os.path.dirname(
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
)
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
),
),
storage_dir=self.storage_path,
)
@ -197,7 +203,7 @@ class IdentityContext:
# Register delivery callback
self.message_router.register_delivery_callback(
lambda msg: self.app.on_lxmf_delivery(msg, context=self)
lambda msg: self.app.on_lxmf_delivery(msg, context=self),
)
# 5. Initialize Handlers and Managers
@ -224,6 +230,15 @@ class IdentityContext:
enabled=translator_enabled,
)
self.bot_handler = BotHandler(
identity_path=self.storage_path,
config_manager=self.config,
)
try:
self.bot_handler.restore_enabled_bots()
except Exception as exc:
print(f"Failed to restore bots: {exc}")
# Initialize managers
self.telephone_manager = TelephoneManager(
self.identity,
@ -236,17 +251,19 @@ class IdentityContext:
)
self.telephone_manager.on_initiation_status_callback = (
lambda status, target: self.app.on_telephone_initiation_status(
status, target, context=self
status,
target,
context=self,
)
)
self.telephone_manager.register_ringing_callback(
lambda call: self.app.on_incoming_telephone_call(call, context=self)
lambda call: self.app.on_incoming_telephone_call(call, context=self),
)
self.telephone_manager.register_established_callback(
lambda call: self.app.on_telephone_call_established(call, context=self)
lambda call: self.app.on_telephone_call_established(call, context=self),
)
self.telephone_manager.register_ended_callback(
lambda call: self.app.on_telephone_call_ended(call, context=self)
lambda call: self.app.on_telephone_call_ended(call, context=self),
)
# Only initialize telephone hardware/profile if not in emergency mode
@ -287,7 +304,7 @@ class IdentityContext:
):
if not self.docs_manager.has_docs():
print(
f"Triggering initial documentation download for {self.identity_hash}..."
f"Triggering initial documentation download for {self.identity_hash}...",
)
self.docs_manager.update_docs()
self.config.initial_docs_download_attempted.set(True)
@ -338,13 +355,23 @@ class IdentityContext:
AnnounceHandler(
"lxst.telephony",
lambda aspect, dh, ai, ad, aph: self.app.on_telephone_announce_received(
aspect, dh, ai, ad, aph, context=self
aspect,
dh,
ai,
ad,
aph,
context=self,
),
),
AnnounceHandler(
"lxmf.delivery",
lambda aspect, dh, ai, ad, aph: self.app.on_lxmf_announce_received(
aspect, dh, ai, ad, aph, context=self
aspect,
dh,
ai,
ad,
aph,
context=self,
),
),
AnnounceHandler(
@ -354,7 +381,12 @@ class IdentityContext:
ai,
ad,
aph: self.app.on_lxmf_propagation_announce_received(
aspect, dh, ai, ad, aph, context=self
aspect,
dh,
ai,
ad,
aph,
context=self,
),
),
AnnounceHandler(
@ -364,7 +396,12 @@ class IdentityContext:
ai,
ad,
aph: self.app.on_nomadnet_node_announce_received(
aspect, dh, ai, ad, aph, context=self
aspect,
dh,
ai,
ad,
aph,
context=self,
),
),
]
@ -389,7 +426,7 @@ class IdentityContext:
if self.message_router:
if hasattr(self.message_router, "delivery_destinations"):
for dest_hash in list(
self.message_router.delivery_destinations.keys()
self.message_router.delivery_destinations.keys(),
):
dest = self.message_router.delivery_destinations[dest_hash]
RNS.Transport.deregister_destination(dest)
@ -399,7 +436,7 @@ class IdentityContext:
and self.message_router.propagation_destination
):
RNS.Transport.deregister_destination(
self.message_router.propagation_destination
self.message_router.propagation_destination,
)
if self.telephone_manager and self.telephone_manager.telephone:
@ -408,7 +445,7 @@ class IdentityContext:
and self.telephone_manager.telephone.destination
):
RNS.Transport.deregister_destination(
self.telephone_manager.telephone.destination
self.telephone_manager.telephone.destination,
)
self.app.cleanup_rns_state_for_identity(self.identity.hash)
@ -423,7 +460,7 @@ class IdentityContext:
self.message_router.exit_handler()
except Exception as e:
print(
f"Error while tearing down LXMRouter for {self.identity_hash}: {e}"
f"Error while tearing down LXMRouter for {self.identity_hash}: {e}",
)
# 4. Stop telephone and voicemail
@ -432,16 +469,22 @@ class IdentityContext:
self.telephone_manager.teardown()
except Exception as e:
print(
f"Error while tearing down telephone for {self.identity_hash}: {e}"
f"Error while tearing down telephone for {self.identity_hash}: {e}",
)
if self.bot_handler:
try:
self.bot_handler.stop_all()
except Exception as e:
print(f"Error while stopping bots for {self.identity_hash}: {e}")
if self.database:
try:
# 1. Checkpoint WAL and close database cleanly to ensure file is stable for hashing
self.database._checkpoint_and_close()
except Exception as e:
print(
f"Error closing database during teardown for {self.identity_hash}: {e}"
f"Error closing database during teardown for {self.identity_hash}: {e}",
)
# 2. Save integrity manifest AFTER closing to capture final stable state

View file

@ -50,7 +50,7 @@ class IdentityManager:
metadata = None
if os.path.exists(metadata_path):
try:
with open(metadata_path, "r") as f:
with open(metadata_path) as f:
metadata = json.load(f)
except Exception:
pass
@ -62,10 +62,10 @@ class IdentityManager:
"display_name": metadata.get("display_name", "Anonymous Peer"),
"icon_name": metadata.get("icon_name"),
"icon_foreground_colour": metadata.get(
"icon_foreground_colour"
"icon_foreground_colour",
),
"icon_background_colour": metadata.get(
"icon_background_colour"
"icon_background_colour",
),
"lxmf_address": metadata.get("lxmf_address"),
"lxst_address": metadata.get("lxst_address"),
@ -137,14 +137,17 @@ class IdentityManager:
def create_identity(self, display_name=None):
new_identity = RNS.Identity(create_keys=True)
identity_hash = new_identity.hash.hex()
return self._save_new_identity(new_identity, display_name or "Anonymous Peer")
def _save_new_identity(self, identity, display_name):
identity_hash = identity.hash.hex()
identity_dir = os.path.join(self.storage_dir, "identities", identity_hash)
os.makedirs(identity_dir, exist_ok=True)
identity_file = os.path.join(identity_dir, "identity")
with open(identity_file, "wb") as f:
f.write(new_identity.get_private_key())
f.write(identity.get_private_key())
db_path = os.path.join(identity_dir, "database.db")
@ -160,7 +163,7 @@ class IdentityManager:
# Save metadata
metadata = {
"display_name": display_name or "Anonymous Peer",
"display_name": display_name,
"icon_name": None,
"icon_foreground_colour": None,
"icon_background_colour": None,
@ -171,7 +174,7 @@ class IdentityManager:
return {
"hash": identity_hash,
"display_name": display_name or "Anonymous Peer",
"display_name": display_name,
}
def update_metadata_cache(self, identity_hash: str, metadata: dict):
@ -185,7 +188,7 @@ class IdentityManager:
existing_metadata = {}
if os.path.exists(metadata_path):
try:
with open(metadata_path, "r") as f:
with open(metadata_path) as f:
existing_metadata = json.load(f)
except Exception:
pass
@ -206,20 +209,20 @@ class IdentityManager:
return False
def restore_identity_from_bytes(self, identity_bytes: bytes) -> dict:
target_path = self.identity_file_path or os.path.join(
self.storage_dir,
"identity",
)
os.makedirs(os.path.dirname(target_path), exist_ok=True)
with open(target_path, "wb") as f:
f.write(identity_bytes)
return {"path": target_path, "size": os.path.getsize(target_path)}
try:
# We use RNS.Identity.from_bytes to validate and get the hash
identity = RNS.Identity.from_bytes(identity_bytes)
if not identity:
raise ValueError("Could not load identity from bytes")
return self._save_new_identity(identity, "Restored Identity")
except Exception as exc:
raise ValueError(f"Failed to restore identity: {exc}")
def restore_identity_from_base32(self, base32_value: str) -> dict:
try:
identity_bytes = base64.b32decode(base32_value, casefold=True)
return self.restore_identity_from_bytes(identity_bytes)
except Exception as exc:
msg = f"Invalid base32 identity: {exc}"
raise ValueError(msg) from exc
return self.restore_identity_from_bytes(identity_bytes)

View file

@ -1,8 +1,8 @@
import os
import hashlib
import json
from pathlib import Path
import os
from datetime import UTC, datetime
from pathlib import Path
class IntegrityManager:
@ -30,7 +30,7 @@ class IntegrityManager:
return True, ["Initial run - no manifest yet"]
try:
with open(self.manifest_path, "r") as f:
with open(self.manifest_path) as f:
manifest = json.load(f)
issues = []
@ -39,7 +39,7 @@ class IntegrityManager:
db_rel = str(self.database_path.relative_to(self.storage_dir))
actual_db_hash = self._hash_file(self.database_path)
if actual_db_hash and actual_db_hash != manifest.get("files", {}).get(
db_rel
db_rel,
):
issues.append(f"Database modified: {db_rel}")
@ -70,7 +70,8 @@ class IntegrityManager:
m_time = manifest.get("time", "Unknown")
m_id = manifest.get("identity", "Unknown")
issues.insert(
0, f"Last integrity snapshot: {m_date} {m_time} (Identity: {m_id})"
0,
f"Last integrity snapshot: {m_date} {m_time} (Identity: {m_id})",
)
# Check if identity matches
@ -84,7 +85,7 @@ class IntegrityManager:
self.issues = issues
return len(issues) == 0, issues
except Exception as e:
return False, [f"Integrity check failed: {str(e)}"]
return False, [f"Integrity check failed: {e!s}"]
def save_manifest(self):
"""Snapshot the current state of critical files."""

View file

@ -3,10 +3,11 @@ import time
import RNS
from RNS.Interfaces.Interface import Interface
from websockets.sync.server import Server, ServerConnection, serve
from meshchatx.src.backend.interfaces.WebsocketClientInterface import (
WebsocketClientInterface,
)
from websockets.sync.server import Server, ServerConnection, serve
class WebsocketServerInterface(Interface):

View file

@ -1,6 +1,8 @@
import base64
import json
import LXMF
from meshchatx.src.backend.telemetry_utils import Telemeter

View file

@ -191,9 +191,9 @@ class MapManager:
for z in zoom_levels:
x1, y1 = self._lonlat_to_tile(min_lon, max_lat, z)
x2, y2 = self._lonlat_to_tile(max_lon, min_lat, z)
for x in range(x1, x2 + 1):
for y in range(y1, y2 + 1):
tiles_to_download.append((z, x, y))
tiles_to_download.extend(
(z, x, y) for x in range(x1, x2 + 1) for y in range(y1, y2 + 1)
)
total_tiles = len(tiles_to_download)
self._export_progress[export_id]["total"] = total_tiles
@ -265,7 +265,7 @@ class MapManager:
return None
with concurrent.futures.ThreadPoolExecutor(
max_workers=max_workers
max_workers=max_workers,
) as executor:
future_to_tile = {
executor.submit(download_tile, tile): tile
@ -299,7 +299,8 @@ class MapManager:
):
try:
cursor.executemany(
"INSERT INTO tiles VALUES (?, ?, ?, ?)", batch_data
"INSERT INTO tiles VALUES (?, ?, ?, ?)",
batch_data,
)
conn.commit()
batch_data = []

View file

@ -1,5 +1,5 @@
import re
import html
import re
class MarkdownRenderer:
@ -24,12 +24,15 @@ class MarkdownRenderer:
code = match.group(2)
placeholder = f"[[CB{len(code_blocks)}]]"
code_blocks.append(
f'<pre class="bg-gray-800 dark:bg-zinc-900 text-zinc-100 dark:text-zinc-100 p-4 rounded-lg my-4 overflow-x-auto border border-gray-700 dark:border-zinc-800 font-mono text-sm"><code class="language-{lang} text-inherit">{code}</code></pre>'
f'<pre class="bg-gray-800 dark:bg-zinc-900 text-zinc-100 dark:text-zinc-100 p-4 rounded-lg my-4 overflow-x-auto border border-gray-700 dark:border-zinc-800 font-mono text-sm"><code class="language-{lang} text-inherit">{code}</code></pre>',
)
return placeholder
text = re.sub(
r"```(\w+)?\n(.*?)\n```", code_block_placeholder, text, flags=re.DOTALL
r"```(\w+)?\n(.*?)\n```",
code_block_placeholder,
text,
flags=re.DOTALL,
)
# Horizontal Rules
@ -134,7 +137,10 @@ class MarkdownRenderer:
return f'<ul class="my-4 space-y-1">{html_items}</ul>'
text = re.sub(
r"((?:^[*-] .*\n?)+)", unordered_list_repl, text, flags=re.MULTILINE
r"((?:^[*-] .*\n?)+)",
unordered_list_repl,
text,
flags=re.MULTILINE,
)
def ordered_list_repl(match):
@ -146,7 +152,10 @@ class MarkdownRenderer:
return f'<ol class="my-4 space-y-1">{html_items}</ol>'
text = re.sub(
r"((?:^\d+\. .*\n?)+)", ordered_list_repl, text, flags=re.MULTILINE
r"((?:^\d+\. .*\n?)+)",
ordered_list_repl,
text,
flags=re.MULTILINE,
)
# Paragraphs - double newline to p tag
@ -169,7 +178,7 @@ class MarkdownRenderer:
# Replace single newlines with <br> for line breaks within paragraphs
part = part.replace("\n", "<br>")
processed_parts.append(
f'<p class="my-4 leading-relaxed text-gray-800 dark:text-zinc-200">{part}</p>'
f'<p class="my-4 leading-relaxed text-gray-800 dark:text-zinc-200">{part}</p>',
)
text = "\n".join(processed_parts)

View file

@ -9,8 +9,7 @@ from LXMF import LXMRouter
def create_lxmf_router(identity, storagepath, propagation_cost=None):
"""
Creates an LXMF.LXMRouter instance safely, avoiding signal handler crashes
"""Creates an LXMF.LXMRouter instance safely, avoiding signal handler crashes
when called from non-main threads.
"""
if propagation_cost is None:

View file

@ -77,7 +77,11 @@ class MessageHandler:
) m2 ON m1.peer_hash = m2.peer_hash AND m1.timestamp = m2.max_ts
LEFT JOIN announces a ON a.destination_hash = m1.peer_hash
LEFT JOIN custom_destination_display_names c ON c.destination_hash = m1.peer_hash
LEFT JOIN contacts con ON con.remote_identity_hash = m1.peer_hash
LEFT JOIN contacts con ON (
con.remote_identity_hash = m1.peer_hash OR
con.lxmf_address = m1.peer_hash OR
con.lxst_address = m1.peer_hash
)
LEFT JOIN lxmf_user_icons i ON i.destination_hash = m1.peer_hash
LEFT JOIN lxmf_conversation_read_state r ON r.destination_hash = m1.peer_hash
"""
@ -86,7 +90,7 @@ class MessageHandler:
if filter_unread:
where_clauses.append(
"(r.last_read_at IS NULL OR m1.timestamp > strftime('%s', r.last_read_at))"
"(r.last_read_at IS NULL OR m1.timestamp > strftime('%s', r.last_read_at))",
)
if filter_failed:
@ -94,7 +98,7 @@ class MessageHandler:
if filter_has_attachments:
where_clauses.append(
"(m1.fields IS NOT NULL AND m1.fields != '{}' AND m1.fields != '')"
"(m1.fields IS NOT NULL AND m1.fields != '{}' AND m1.fields != '')",
)
if search:
@ -105,7 +109,7 @@ class MessageHandler:
OR m1.peer_hash IN (SELECT peer_hash FROM lxmf_messages WHERE title LIKE ? OR content LIKE ?))
""")
params.extend(
[like_term, like_term, like_term, like_term, like_term, like_term]
[like_term, like_term, like_term, like_term, like_term, like_term],
)
if where_clauses:

View file

@ -64,7 +64,8 @@ class PersistentLogHandler(logging.Handler):
# Regex to extract IP and User-Agent from aiohttp access log
# Format: IP [date] "GET ..." status size "referer" "User-Agent"
match = re.search(
r"^([\d\.\:]+) .* \"[^\"]+\" \d+ \d+ \"[^\"]*\" \"([^\"]+)\"", message
r"^([\d\.\:]+) .* \"[^\"]+\" \d+ \d+ \"[^\"]*\" \"([^\"]+)\"",
message,
)
if match:
ip = match.group(1)
@ -180,7 +181,13 @@ class PersistentLogHandler(logging.Handler):
self.flush_lock.release()
def get_logs(
self, limit=100, offset=0, search=None, level=None, module=None, is_anomaly=None
self,
limit=100,
offset=0,
search=None,
level=None,
module=None,
is_anomaly=None,
):
if self.database:
# Flush current buffer first to ensure we have latest logs
@ -196,34 +203,33 @@ class PersistentLogHandler(logging.Handler):
module=module,
is_anomaly=is_anomaly,
)
else:
# Fallback to in-memory buffer if DB not yet available
logs = list(self.logs_buffer)
if search:
logs = [
log
for log in logs
if search.lower() in log["message"].lower()
or search.lower() in log["module"].lower()
]
if level:
logs = [log for log in logs if log["level"] == level]
if is_anomaly is not None:
logs = [
log
for log in logs
if log["is_anomaly"] == (1 if is_anomaly else 0)
]
# Fallback to in-memory buffer if DB not yet available
logs = list(self.logs_buffer)
if search:
logs = [
log
for log in logs
if search.lower() in log["message"].lower()
or search.lower() in log["module"].lower()
]
if level:
logs = [log for log in logs if log["level"] == level]
if is_anomaly is not None:
logs = [
log for log in logs if log["is_anomaly"] == (1 if is_anomaly else 0)
]
# Sort descending
logs.sort(key=lambda x: x["timestamp"], reverse=True)
return logs[offset : offset + limit]
# Sort descending
logs.sort(key=lambda x: x["timestamp"], reverse=True)
return logs[offset : offset + limit]
def get_total_count(self, search=None, level=None, module=None, is_anomaly=None):
with self.lock:
if self.database:
return self.database.debug_logs.get_total_count(
search=search, level=level, module=module, is_anomaly=is_anomaly
search=search,
level=level,
module=module,
is_anomaly=is_anomaly,
)
else:
return len(self.logs_buffer)
return len(self.logs_buffer)

View file

@ -1,16 +1,16 @@
import sys
import os
import traceback
import platform
import shutil
import sqlite3
import sys
import traceback
import psutil
import RNS
class CrashRecovery:
"""
A diagnostic utility that intercepts application crashes and provides
"""A diagnostic utility that intercepts application crashes and provides
meaningful error reports and system state analysis.
"""
@ -33,18 +33,14 @@ class CrashRecovery:
self.enabled = False
def install(self):
"""
Installs the crash recovery exception hook into the system.
"""
"""Installs the crash recovery exception hook into the system."""
if not self.enabled:
return
sys.excepthook = self.handle_exception
def disable(self):
"""
Disables the crash recovery system manually.
"""
"""Disables the crash recovery system manually."""
self.enabled = False
def update_paths(
@ -54,9 +50,7 @@ class CrashRecovery:
public_dir=None,
reticulum_config_dir=None,
):
"""
Updates the internal paths used for system diagnosis.
"""
"""Updates the internal paths used for system diagnosis."""
if storage_dir:
self.storage_dir = storage_dir
if database_path:
@ -67,9 +61,7 @@ class CrashRecovery:
self.reticulum_config_dir = reticulum_config_dir
def handle_exception(self, exc_type, exc_value, exc_traceback):
"""
Intercepts unhandled exceptions to provide a detailed diagnosis report.
"""
"""Intercepts unhandled exceptions to provide a detailed diagnosis report."""
# Let keyboard interrupts pass through normally
if issubclass(exc_type, KeyboardInterrupt):
sys.__excepthook__(exc_type, exc_value, exc_traceback)
@ -100,13 +92,13 @@ class CrashRecovery:
out.write("Recovery Suggestions:\n")
out.write(" 1. Review the 'System Environment Diagnosis' section above.\n")
out.write(
" 2. Verify that all dependencies are installed (poetry install or pip install -r requirements.txt).\n"
" 2. Verify that all dependencies are installed (poetry install or pip install -r requirements.txt).\n",
)
out.write(
" 3. If database corruption is suspected, try starting with --auto-recover.\n"
" 3. If database corruption is suspected, try starting with --auto-recover.\n",
)
out.write(
" 4. If the issue persists, report it to Ivan over another LXMF client: 7cc8d66b4f6a0e0e49d34af7f6077b5a\n"
" 4. If the issue persists, report it to Ivan over another LXMF client: 7cc8d66b4f6a0e0e49d34af7f6077b5a\n",
)
out.write("=" * 70 + "\n\n")
out.flush()
@ -115,12 +107,10 @@ class CrashRecovery:
sys.exit(1)
def run_diagnosis(self, file=sys.stderr):
"""
Performs a series of OS-agnostic checks on the application's environment.
"""
"""Performs a series of OS-agnostic checks on the application's environment."""
# Basic System Info
file.write(
f"- OS: {platform.system()} {platform.release()} ({platform.machine()})\n"
f"- OS: {platform.system()} {platform.release()} ({platform.machine()})\n",
)
file.write(f"- Python: {sys.version.split()[0]}\n")
@ -128,7 +118,7 @@ class CrashRecovery:
try:
mem = psutil.virtual_memory()
file.write(
f"- Memory: {mem.percent}% used ({mem.available / (1024**2):.1f} MB available)\n"
f"- Memory: {mem.percent}% used ({mem.available / (1024**2):.1f} MB available)\n",
)
if mem.percent > 95:
file.write(" [CRITICAL] System memory is dangerously low!\n")
@ -140,12 +130,12 @@ class CrashRecovery:
file.write(f"- Storage Path: {self.storage_dir}\n")
if not os.path.exists(self.storage_dir):
file.write(
" [ERROR] Storage path does not exist. Check MESHCHAT_STORAGE_DIR.\n"
" [ERROR] Storage path does not exist. Check MESHCHAT_STORAGE_DIR.\n",
)
else:
if not os.access(self.storage_dir, os.W_OK):
file.write(
" [ERROR] Storage path is NOT writable. Check filesystem permissions.\n"
" [ERROR] Storage path is NOT writable. Check filesystem permissions.\n",
)
try:
@ -154,7 +144,7 @@ class CrashRecovery:
file.write(f" - Disk Space: {free_mb:.1f} MB free\n")
if free_mb < 50:
file.write(
" [CRITICAL] Disk space is critically low (< 50MB)!\n"
" [CRITICAL] Disk space is critically low (< 50MB)!\n",
)
except Exception:
pass
@ -165,27 +155,28 @@ class CrashRecovery:
if os.path.exists(self.database_path):
if os.path.getsize(self.database_path) == 0:
file.write(
" [WARNING] Database file exists but is empty (0 bytes).\n"
" [WARNING] Database file exists but is empty (0 bytes).\n",
)
else:
try:
# Open in read-only mode for safety during crash handling
conn = sqlite3.connect(
f"file:{self.database_path}?mode=ro", uri=True
f"file:{self.database_path}?mode=ro",
uri=True,
)
cursor = conn.cursor()
cursor.execute("PRAGMA integrity_check")
res = cursor.fetchone()[0]
if res != "ok":
file.write(
f" [ERROR] Database corruption detected: {res}\n"
f" [ERROR] Database corruption detected: {res}\n",
)
else:
file.write(" - Integrity: OK\n")
conn.close()
except sqlite3.DatabaseError as e:
file.write(
f" [ERROR] Database is unreadable or not a SQLite file: {e}\n"
f" [ERROR] Database is unreadable or not a SQLite file: {e}\n",
)
except Exception as e:
file.write(f" [ERROR] Database check failed: {e}\n")
@ -197,13 +188,13 @@ class CrashRecovery:
file.write(f"- Frontend Assets: {self.public_dir}\n")
if not os.path.exists(self.public_dir):
file.write(
" [ERROR] Frontend directory is missing. Web interface will fail to load.\n"
" [ERROR] Frontend directory is missing. Web interface will fail to load.\n",
)
else:
index_path = os.path.join(self.public_dir, "index.html")
if not os.path.exists(index_path):
file.write(
" [ERROR] index.html not found in frontend directory!\n"
" [ERROR] index.html not found in frontend directory!\n",
)
else:
file.write(" - Frontend Status: Assets verified\n")
@ -212,9 +203,7 @@ class CrashRecovery:
self.run_reticulum_diagnosis(file=file)
def run_reticulum_diagnosis(self, file=sys.stderr):
"""
Diagnoses the Reticulum Network Stack environment.
"""
"""Diagnoses the Reticulum Network Stack environment."""
file.write("- Reticulum Network Stack:\n")
# Check config directory
@ -231,11 +220,11 @@ class CrashRecovery:
else:
try:
# Basic config validation
with open(config_file, "r") as f:
with open(config_file) as f:
content = f.read()
if "[reticulum]" not in content:
file.write(
" [ERROR] Reticulum config file is invalid (missing [reticulum] section).\n"
" [ERROR] Reticulum config file is invalid (missing [reticulum] section).\n",
)
else:
file.write(" - Config File: OK\n")
@ -255,7 +244,7 @@ class CrashRecovery:
if os.path.exists(logfile):
file.write(f" - Recent Log Entries ({logfile}):\n")
try:
with open(logfile, "r") as f:
with open(logfile) as f:
lines = f.readlines()
if not lines:
file.write(" (Log file is empty)\n")
@ -283,7 +272,7 @@ class CrashRecovery:
file.write(f" > {iface} [{status}]\n")
else:
file.write(
" - Active Interfaces: None registered (Reticulum may not be initialized yet)\n"
" - Active Interfaces: None registered (Reticulum may not be initialized yet)\n",
)
except Exception:
pass
@ -295,7 +284,7 @@ class CrashRecovery:
for conn in psutil.net_connections():
if conn.laddr.port == port and conn.status == "LISTEN":
file.write(
f" [ALERT] Port {port} is already in use by PID {conn.pid}. Potential conflict.\n"
f" [ALERT] Port {port} is already in use by PID {conn.pid}. Potential conflict.\n",
)
except Exception:
pass

View file

@ -55,7 +55,7 @@ class RNPathHandler:
"timestamp": entry.get("timestamp"),
"announce_hash": announce_hash,
"state": state,
}
},
)
# Sort: Responsive first, then by hops, then by interface
@ -64,19 +64,23 @@ class RNPathHandler:
0 if e["state"] == RNS.Transport.STATE_RESPONSIVE else 1,
e["hops"],
e["interface"],
)
),
)
total = len(formatted_table)
responsive_count = len(
[e for e in formatted_table if e["state"] == RNS.Transport.STATE_RESPONSIVE]
[
e
for e in formatted_table
if e["state"] == RNS.Transport.STATE_RESPONSIVE
],
)
unresponsive_count = len(
[
e
for e in formatted_table
if e["state"] == RNS.Transport.STATE_UNRESPONSIVE
]
],
)
# Pagination
@ -96,17 +100,16 @@ class RNPathHandler:
def get_rate_table(self):
table = self.reticulum.get_rate_table()
formatted_table = []
for entry in table:
formatted_table.append(
{
"hash": entry["hash"].hex(),
"last": entry["last"],
"timestamps": entry["timestamps"],
"rate_violations": entry["rate_violations"],
"blocked_until": entry["blocked_until"],
}
)
formatted_table = [
{
"hash": entry["hash"].hex(),
"last": entry["last"],
"timestamps": entry["timestamps"],
"rate_violations": entry["rate_violations"],
"blocked_until": entry["blocked_until"],
}
for entry in table
]
return sorted(formatted_table, key=lambda e: e["last"])
def drop_path(self, destination_hash: str) -> bool:

View file

@ -1,5 +1,6 @@
import time
from typing import Any
import RNS

View file

@ -41,7 +41,11 @@ class TelephoneManager:
# 6: STATUS_ESTABLISHED
def __init__(
self, identity: RNS.Identity, config_manager=None, storage_dir=None, db=None
self,
identity: RNS.Identity,
config_manager=None,
storage_dir=None,
db=None,
):
self.identity = identity
self.config_manager = config_manager
@ -177,7 +181,8 @@ class TelephoneManager:
# Pack display name in LXMF-compatible app data format
app_data = msgpack.packb([display_name, None, None])
self.telephone.destination.announce(
app_data=app_data, attached_interface=attached_interface
app_data=app_data,
attached_interface=attached_interface,
)
self.telephone.last_announce = time.time()
else:
@ -190,7 +195,8 @@ class TelephoneManager:
if self.on_initiation_status_callback:
try:
self.on_initiation_status_callback(
self.initiation_status, self.initiation_target_hash
self.initiation_status,
self.initiation_target_hash,
)
except Exception as e:
RNS.log(
@ -229,7 +235,7 @@ class TelephoneManager:
if not announce:
# 3) By identity_hash field (if user entered identity hash but we missed recall, or other announce types)
announces = self.db.announces.get_filtered_announces(
identity_hash=target_hash_hex
identity_hash=target_hash_hex,
)
if announces:
announce = announces[0]
@ -248,7 +254,7 @@ class TelephoneManager:
if announce.get("identity_public_key"):
try:
return RNS.Identity.from_bytes(
base64.b64decode(announce["identity_public_key"])
base64.b64decode(announce["identity_public_key"]),
)
except Exception:
pass
@ -297,7 +303,7 @@ class TelephoneManager:
# Use a thread for the blocking LXST call, but monitor status for early exit
# if established elsewhere or timed out/hung up
call_task = asyncio.create_task(
asyncio.to_thread(self.telephone.call, destination_identity)
asyncio.to_thread(self.telephone.call, destination_identity),
)
start_wait = time.time()
@ -340,7 +346,7 @@ class TelephoneManager:
return self.telephone.active_call
except Exception as e:
self._update_initiation_status(f"Failed: {str(e)}")
self._update_initiation_status(f"Failed: {e!s}")
await asyncio.sleep(3)
raise
finally:
@ -379,7 +385,8 @@ class TelephoneManager:
self.telephone.audio_input.start()
except Exception as e:
RNS.log(
f"Failed to start audio input for unmute: {e}", RNS.LOG_ERROR
f"Failed to start audio input for unmute: {e}",
RNS.LOG_ERROR,
)
# Still call the internal method just in case
@ -415,7 +422,8 @@ class TelephoneManager:
self.telephone.audio_output.start()
except Exception as e:
RNS.log(
f"Failed to start audio output for unmute: {e}", RNS.LOG_ERROR
f"Failed to start audio output for unmute: {e}",
RNS.LOG_ERROR,
)
# Still call the internal method just in case

View file

@ -556,7 +556,8 @@ class VoicemailManager:
os.remove(filepath)
os.rename(temp_path, filepath)
RNS.log(
f"Voicemail: Fixed recording format for {filepath}", RNS.LOG_DEBUG
f"Voicemail: Fixed recording format for {filepath}",
RNS.LOG_DEBUG,
)
else:
RNS.log(

View file

@ -1,7 +1,6 @@
import asyncio
import json
import threading
from typing import Optional
import numpy as np
import RNS
@ -89,9 +88,9 @@ class WebAudioBridge:
self.telephone_manager = telephone_manager
self.config_manager = config_manager
self.clients = set()
self.tx_source: Optional[WebAudioSource] = None
self.rx_sink: Optional[WebAudioSink] = None
self.rx_tee: Optional[Tee] = None
self.tx_source: WebAudioSource | None = None
self.rx_sink: WebAudioSink | None = None
self.rx_tee: Tee | None = None
self.loop = asyncio.get_event_loop()
self.lock = threading.Lock()
@ -137,8 +136,8 @@ class WebAudioBridge:
{
"type": "web_audio.ready",
"frame_ms": frame_ms,
}
)
},
),
)
def push_client_frame(self, pcm_bytes: bytes):
@ -173,7 +172,8 @@ class WebAudioBridge:
tele.transmit_mixer.start()
except Exception as exc: # noqa: BLE001
RNS.log(
f"WebAudioBridge: failed to swap transmit path: {exc}", RNS.LOG_ERROR
f"WebAudioBridge: failed to swap transmit path: {exc}",
RNS.LOG_ERROR,
)
def _ensure_rx_tee(self, tele):

View file

@ -630,6 +630,7 @@ export default {
// stop listening for websocket messages
WebSocketConnection.off("message", this.onWebsocketMessage);
GlobalEmitter.off("config-updated", this.onConfigUpdatedExternally);
},
mounted() {
// listen for websocket messages
@ -650,6 +651,8 @@ export default {
this.syncPropagationNode();
});
GlobalEmitter.on("config-updated", this.onConfigUpdatedExternally);
GlobalEmitter.on("keyboard-shortcut", (action) => {
this.handleKeyboardShortcut(action);
});
@ -691,6 +694,12 @@ export default {
}, 15000);
},
methods: {
onConfigUpdatedExternally(newConfig) {
if (!newConfig) return;
this.config = newConfig;
GlobalState.config = newConfig;
this.displayName = newConfig.display_name;
},
applyThemePreference(theme) {
const mode = theme === "dark" ? "dark" : "light";
if (typeof document !== "undefined") {

View file

@ -24,13 +24,23 @@
<div class="p-4 border-b border-gray-200 dark:border-zinc-800">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Notifications</h3>
<button
type="button"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
@click="closeDropdown"
>
<MaterialDesignIcon icon-name="close" class="w-5 h-5" />
</button>
<div class="flex items-center gap-2">
<button
v-if="notifications.length > 0"
type="button"
class="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
@click.stop="clearAllNotifications"
>
Clear
</button>
<button
type="button"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
@click="closeDropdown"
>
<MaterialDesignIcon icon-name="close" class="w-5 h-5" />
</button>
</div>
</div>
</div>
@ -139,6 +149,7 @@ export default {
},
},
},
emits: ["notifications-cleared"],
data() {
return {
isDropdownOpen: false,
@ -233,6 +244,37 @@ export default {
console.error("Failed to mark notifications as viewed", e);
}
},
async clearAllNotifications() {
try {
await window.axios.post("/api/v1/notifications/mark-as-viewed", {
destination_hashes: [],
notification_ids: [],
});
const response = await window.axios.get("/api/v1/lxmf/conversations");
const conversations = response.data.conversations || [];
for (const conversation of conversations) {
if (conversation.is_unread) {
try {
await window.axios.get(
`/api/v1/lxmf/conversations/${conversation.destination_hash}/mark-as-read`
);
} catch (e) {
console.error(`Failed to mark conversation as read: ${conversation.destination_hash}`, e);
}
}
}
const GlobalState = (await import("../js/GlobalState")).default;
GlobalState.unreadConversationsCount = 0;
await this.loadNotifications();
this.$emit("notifications-cleared");
} catch (e) {
console.error("Failed to clear notifications", e);
}
},
onNotificationClick(notification) {
this.closeDropdown();
if (notification.type === "lxmf_message") {

View file

@ -556,6 +556,48 @@
</div>
</div>
<!-- Auto Backups -->
<div v-if="autoBackups.length > 0" class="space-y-6">
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div class="space-y-1">
<div
class="font-black text-gray-900 dark:text-white text-sm tracking-tight flex items-center gap-2"
>
<v-icon icon="mdi-history" size="16" class="text-blue-500"></v-icon>
Automatic Backups
</div>
<div class="text-xs text-gray-500">
Automated daily snapshots of your database.
</div>
</div>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<div
v-for="backup in autoBackups"
:key="backup.path"
class="flex items-center justify-between p-4 rounded-2xl bg-zinc-50 dark:bg-zinc-900 border border-zinc-100 dark:border-zinc-800 hover:border-blue-500/20 transition-all group"
>
<div class="flex flex-col">
<span
class="font-black text-gray-900 dark:text-white text-xs truncate max-w-[150px]"
>{{ backup.name }}</span
>
<span class="text-[10px] font-bold text-gray-400 mt-1 tabular-nums"
>{{ formatBytes(backup.size) }} {{ backup.created_at }}</span
>
</div>
<button
type="button"
class="secondary-chip !px-3 !py-1 !text-[10px] opacity-0 group-hover:opacity-100"
@click="restoreFromSnapshot(backup.path)"
>
Restore
</button>
</div>
</div>
</div>
<!-- Identity Section -->
<div class="bg-red-500/5 p-6 rounded-2xl border border-red-500/10 space-y-6">
<div class="flex items-center gap-4 text-red-500">
@ -686,6 +728,7 @@ export default {
snapshotInProgress: false,
snapshotMessage: "",
snapshotError: "",
autoBackups: [],
identityBackupMessage: "",
identityBackupError: "",
identityBase32: "",
@ -713,6 +756,7 @@ export default {
this.getConfig();
this.getDatabaseHealth();
this.listSnapshots();
this.listAutoBackups();
// Update stats every 5 seconds
this.updateInterval = setInterval(() => {
this.getAppInfo();
@ -738,6 +782,14 @@ export default {
console.log("Failed to list snapshots", e);
}
},
async listAutoBackups() {
try {
const response = await window.axios.get("/api/v1/database/backups");
this.autoBackups = response.data;
} catch (e) {
console.log("Failed to list auto-backups", e);
}
},
async createSnapshot() {
if (this.snapshotInProgress) return;
this.snapshotInProgress = true;
@ -1071,7 +1123,7 @@ export default {
const response = await window.axios.post("/api/v1/identity/restore", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
this.identityRestoreMessage = response.data.message || "Identity restored. Restart app.";
this.identityRestoreMessage = response.data.message || "Identity imported.";
} catch (e) {
this.identityRestoreError = "Identity restore failed";
console.log(e);
@ -1094,7 +1146,7 @@ export default {
const response = await window.axios.post("/api/v1/identity/restore", {
base32: this.identityRestoreBase32.trim(),
});
this.identityRestoreMessage = response.data.message || "Identity restored. Restart app.";
this.identityRestoreMessage = response.data.message || "Identity imported.";
} catch (e) {
this.identityRestoreError = "Identity restore failed";
console.log(e);

View file

@ -516,12 +516,18 @@
:label="$t('call.allow_calls_from_contacts_only')"
@update:model-value="toggleAllowCallsFromContactsOnly"
/>
<Toggle
id="web-audio-toggle"
:model-value="config?.telephone_web_audio_enabled"
label="Browser/Electron Audio"
@update:model-value="onToggleWebAudio"
/>
<div class="flex flex-col gap-1">
<Toggle
id="web-audio-toggle"
:model-value="config?.telephone_web_audio_enabled"
label="Web Audio Bridge"
@update:model-value="onToggleWebAudio"
/>
<div class="text-xs text-gray-500 dark:text-zinc-400 px-1">
Web audio bridge allows web/electron to hook into LXST backend for
passing microphone and audio streams to active telephone calls.
</div>
</div>
</div>
<div class="flex flex-col gap-2 shrink-0">
<!-- <Toggle
@ -676,6 +682,7 @@
<div class="relative shrink-0">
<LxmfUserIcon
:custom-image="
entry.contact_image ||
getContactByHash(entry.remote_identity_hash)?.custom_image
"
:icon-name="entry.remote_icon ? entry.remote_icon.icon_name : ''"
@ -845,18 +852,12 @@
<div class="flex items-center space-x-4">
<div class="shrink-0">
<LxmfUserIcon
v-if="announce.lxmf_user_icon"
:icon-name="announce.lxmf_user_icon.icon_name"
:icon-foreground-colour="announce.lxmf_user_icon.foreground_colour"
:icon-background-colour="announce.lxmf_user_icon.background_colour"
:custom-image="announce.contact_image"
:icon-name="announce.lxmf_user_icon?.icon_name"
:icon-foreground-colour="announce.lxmf_user_icon?.foreground_colour"
:icon-background-colour="announce.lxmf_user_icon?.background_colour"
class="size-10"
/>
<div
v-else
class="size-10 rounded-full bg-blue-50 dark:bg-blue-900/20 text-blue-500 flex items-center justify-center font-bold shrink-0"
>
{{ (announce.display_name || "A")[0].toUpperCase() }}
</div>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between">
@ -1461,16 +1462,34 @@
</div>
</div>
<div class="flex items-center justify-between mt-1">
<span
class="text-[10px] text-gray-500 dark:text-zinc-500 font-mono truncate cursor-pointer hover:text-blue-500 transition-colors"
:title="contact.remote_identity_hash"
@click.stop="copyHash(contact.remote_identity_hash)"
>
{{ formatDestinationHash(contact.remote_identity_hash) }}
</span>
<div class="flex flex-col min-w-0">
<span
class="text-[10px] text-gray-500 dark:text-zinc-500 font-mono truncate cursor-pointer hover:text-blue-500 transition-colors"
:title="contact.remote_identity_hash"
@click.stop="copyHash(contact.remote_identity_hash)"
>
ID: {{ formatDestinationHash(contact.remote_identity_hash) }}
</span>
<span
v-if="contact.lxmf_address"
class="text-[9px] text-gray-400 dark:text-zinc-500 font-mono truncate cursor-pointer hover:text-blue-500 transition-colors"
:title="contact.lxmf_address"
@click.stop="copyHash(contact.lxmf_address)"
>
LXMF: {{ formatDestinationHash(contact.lxmf_address) }}
</span>
<span
v-if="contact.lxst_address"
class="text-[9px] text-gray-400 dark:text-zinc-500 font-mono truncate cursor-pointer hover:text-blue-500 transition-colors"
:title="contact.lxst_address"
@click.stop="copyHash(contact.lxst_address)"
>
LXST: {{ formatDestinationHash(contact.lxst_address) }}
</span>
</div>
<button
type="button"
class="text-[10px] bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400 px-3 py-1 rounded-full font-bold uppercase tracking-wider hover:bg-blue-200 dark:hover:bg-blue-900/50 transition-colors"
class="text-[10px] bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400 px-3 py-1 rounded-full font-bold uppercase tracking-wider hover:bg-blue-200 dark:hover:bg-blue-900/50 transition-colors shrink-0"
@click="
destinationHash =
contact.remote_telephony_hash ||
@ -2044,6 +2063,34 @@
placeholder="e.g. a39610c89d18bb48c73e429582423c24"
/>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label
class="block text-xs font-bold text-gray-500 dark:text-zinc-400 uppercase tracking-wider mb-1.5 ml-1"
>
{{ $t("app.lxmf_address") }}
</label>
<input
v-model="contactForm.lxmf_address"
type="text"
class="input-field font-mono text-xs"
placeholder="Optional"
/>
</div>
<div>
<label
class="block text-xs font-bold text-gray-500 dark:text-zinc-400 uppercase tracking-wider mb-1.5 ml-1"
>
LXST Address
</label>
<input
v-model="contactForm.lxst_address"
type="text"
class="input-field font-mono text-xs"
placeholder="Optional"
/>
</div>
</div>
<div>
<label
class="block text-xs font-bold text-gray-500 dark:text-zinc-400 uppercase tracking-wider mb-1.5 ml-1"
@ -2944,6 +2991,8 @@ export default {
this.contactForm = {
name: "",
remote_identity_hash: "",
lxmf_address: "",
lxst_address: "",
preferred_ringtone_id: null,
custom_image: null,
};
@ -2955,6 +3004,8 @@ export default {
id: contact.id,
name: contact.name,
remote_identity_hash: contact.remote_identity_hash,
lxmf_address: contact.lxmf_address || "",
lxst_address: contact.lxst_address || "",
preferred_ringtone_id: contact.preferred_ringtone_id,
custom_image: contact.custom_image,
};

View file

@ -1076,6 +1076,207 @@
</template>
</ExpandingSection>
<ExpandingSection>
<template #title>Interface Discovery</template>
<template #content>
<div class="p-2 space-y-3">
<div class="flex items-center">
<div class="flex flex-col mr-auto">
<FormLabel class="mb-1">Advertise this Interface</FormLabel>
<FormSubLabel>
Broadcasts connection details so peers can find and connect to this interface.
</FormSubLabel>
</div>
<Toggle v-model="discovery.discoverable" class="my-auto mx-2" />
</div>
<div class="text-sm text-gray-500 dark:text-zinc-300">
LXMF must be installed to publish discovery announces. When enabled, Reticulum handles
signing, stamping, and periodic announces for this interface.
</div>
<div v-if="discovery.discoverable" class="space-y-3">
<div>
<FormLabel class="mb-1">Discovery Name</FormLabel>
<input
v-model="discovery.discovery_name"
type="text"
placeholder="Human friendly name"
class="input-field"
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<FormLabel class="mb-1">Reachable On</FormLabel>
<input
v-model="discovery.reachable_on"
type="text"
placeholder="Hostname, IP, or resolver script path"
class="input-field"
/>
</div>
<div>
<FormLabel class="mb-1">Announce Interval (minutes)</FormLabel>
<input
v-model.number="discovery.announce_interval"
type="number"
min="5"
class="input-field"
/>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<FormLabel class="mb-1">Stamp Value</FormLabel>
<input
v-model.number="discovery.discovery_stamp_value"
type="number"
min="1"
class="input-field"
/>
</div>
<div class="flex items-center">
<Toggle id="discovery-encrypt" v-model="discovery.discovery_encrypt" />
<FormLabel for="discovery-encrypt" class="ml-2">Encrypt Announces</FormLabel>
</div>
</div>
<div class="flex items-center">
<Toggle id="publish-ifac" v-model="discovery.publish_ifac" />
<FormLabel for="publish-ifac" class="ml-2">Include IFAC Credentials</FormLabel>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
<div>
<FormLabel class="mb-1">Latitude</FormLabel>
<input
v-model.number="discovery.latitude"
type="number"
step="0.00001"
class="input-field"
/>
</div>
<div>
<FormLabel class="mb-1">Longitude</FormLabel>
<input
v-model.number="discovery.longitude"
type="number"
step="0.00001"
class="input-field"
/>
</div>
<div>
<FormLabel class="mb-1">Height (m)</FormLabel>
<input v-model.number="discovery.height" type="number" class="input-field" />
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
<div>
<FormLabel class="mb-1">Discovery Frequency (Hz)</FormLabel>
<input
v-model.number="discovery.discovery_frequency"
type="number"
class="input-field"
/>
</div>
<div>
<FormLabel class="mb-1">Discovery Bandwidth (Hz)</FormLabel>
<input
v-model.number="discovery.discovery_bandwidth"
type="number"
class="input-field"
/>
</div>
<div>
<FormLabel class="mb-1">Discovery Modulation</FormLabel>
<input
v-model="discovery.discovery_modulation"
type="text"
placeholder="e.g. LoRa"
class="input-field"
/>
</div>
</div>
<div class="text-xs text-gray-500 dark:text-zinc-400">
If announce encryption is enabled, a valid network identity path is required in the
Reticulum configuration.
</div>
</div>
</div>
</template>
</ExpandingSection>
<ExpandingSection>
<template #title>Discover Interfaces (Peer)</template>
<template #content>
<div class="p-2 space-y-3">
<div class="flex items-center">
<div class="flex flex-col mr-auto">
<FormLabel class="mb-1">Enable Discovery Listener</FormLabel>
<FormSubLabel>
Listen for announced interfaces and optionally auto-connect to them.
</FormSubLabel>
</div>
<Toggle v-model="reticulumDiscovery.discover_interfaces" class="my-auto mx-2" />
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<FormLabel class="mb-1">Allowed Sources (comma separated)</FormLabel>
<input
v-model="reticulumDiscovery.interface_discovery_sources"
type="text"
placeholder="Identity hashes"
class="input-field"
/>
</div>
<div>
<FormLabel class="mb-1">Required Stamp Value</FormLabel>
<input
v-model.number="reticulumDiscovery.required_discovery_value"
type="number"
min="0"
class="input-field"
/>
</div>
<div>
<FormLabel class="mb-1">Auto-connect Slots</FormLabel>
<input
v-model.number="reticulumDiscovery.autoconnect_discovered_interfaces"
type="number"
min="0"
class="input-field"
/>
<FormSubLabel>Set to 0 to disable auto-connect.</FormSubLabel>
</div>
<div>
<FormLabel class="mb-1">Network Identity Path</FormLabel>
<input
v-model="reticulumDiscovery.network_identity"
type="text"
placeholder="~/.reticulum/storage/identities/..."
class="input-field"
/>
</div>
</div>
<div class="flex justify-end">
<button
type="button"
class="primary-chip text-xs"
:disabled="savingDiscovery"
@click="saveReticulumDiscoveryConfig"
>
<MaterialDesignIcon
:icon-name="savingDiscovery ? 'progress-clock' : 'content-save'"
class="w-4 h-4"
:class="{ 'animate-spin-reverse': savingDiscovery }"
/>
<span class="ml-1">Save Discovery Preferences</span>
</button>
</div>
</div>
</template>
</ExpandingSection>
<!-- add/save interface button -->
<div class="p-2 bg-white rounded shadow divide-y divide-gray-200 dark:bg-zinc-900">
<button
@ -1100,10 +1301,12 @@ import FormLabel from "../forms/FormLabel.vue";
import FormSubLabel from "../forms/FormSubLabel.vue";
import Toggle from "../forms/Toggle.vue";
import GlobalState from "../../js/GlobalState";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
export default {
name: "AddInterfacePage",
components: {
MaterialDesignIcon,
FormSubLabel,
FormLabel,
ExpandingSection,
@ -1147,6 +1350,32 @@ export default {
ifac_size: null,
},
discovery: {
discoverable: false,
discovery_name: "",
announce_interval: 360,
reachable_on: "",
discovery_stamp_value: 14,
discovery_encrypt: false,
publish_ifac: false,
latitude: null,
longitude: null,
height: null,
discovery_frequency: null,
discovery_bandwidth: null,
discovery_modulation: null,
},
reticulumDiscovery: {
discover_interfaces: false,
interface_discovery_sources: "",
required_discovery_value: null,
autoconnect_discovered_interfaces: 0,
network_identity: "",
},
savingDiscovery: false,
newInterfaceForwardIp: null,
newInterfaceForwardPort: null,
@ -1250,6 +1479,7 @@ export default {
},
mounted() {
this.getConfig();
this.loadReticulumDiscoveryConfig();
this.loadComports();
this.loadCommunityInterfaces();
@ -1279,6 +1509,66 @@ export default {
console.log(e);
}
},
parseBool(value) {
if (typeof value === "string") {
return ["true", "yes", "1", "y", "on"].includes(value.toLowerCase());
}
return Boolean(value);
},
async loadReticulumDiscoveryConfig() {
try {
const response = await window.axios.get(`/api/v1/reticulum/discovery`);
const discovery = response.data?.discovery ?? {};
this.reticulumDiscovery.discover_interfaces = this.parseBool(discovery.discover_interfaces);
this.reticulumDiscovery.interface_discovery_sources = discovery.interface_discovery_sources ?? "";
this.reticulumDiscovery.required_discovery_value =
discovery.required_discovery_value !== undefined &&
discovery.required_discovery_value !== null &&
discovery.required_discovery_value !== ""
? Number(discovery.required_discovery_value)
: null;
this.reticulumDiscovery.autoconnect_discovered_interfaces =
discovery.autoconnect_discovered_interfaces !== undefined &&
discovery.autoconnect_discovered_interfaces !== null &&
discovery.autoconnect_discovered_interfaces !== ""
? Number(discovery.autoconnect_discovered_interfaces)
: 0;
this.reticulumDiscovery.network_identity = discovery.network_identity ?? "";
} catch (e) {
// safe to ignore if discovery config cannot be loaded
console.log(e);
}
},
async saveReticulumDiscoveryConfig() {
if (this.savingDiscovery) return;
this.savingDiscovery = true;
try {
const payload = {
discover_interfaces: this.reticulumDiscovery.discover_interfaces,
interface_discovery_sources: this.reticulumDiscovery.interface_discovery_sources || null,
required_discovery_value:
this.reticulumDiscovery.required_discovery_value === null ||
this.reticulumDiscovery.required_discovery_value === ""
? null
: Number(this.reticulumDiscovery.required_discovery_value),
autoconnect_discovered_interfaces:
this.reticulumDiscovery.autoconnect_discovered_interfaces === null ||
this.reticulumDiscovery.autoconnect_discovered_interfaces === ""
? 0
: Number(this.reticulumDiscovery.autoconnect_discovered_interfaces),
network_identity: this.reticulumDiscovery.network_identity || null,
};
await window.axios.patch(`/api/v1/reticulum/discovery`, payload);
ToastUtils.success("Discovery settings saved");
await this.loadReticulumDiscoveryConfig();
} catch (e) {
ToastUtils.error("Failed to save discovery settings");
console.log(e);
} finally {
this.savingDiscovery = false;
}
},
async loadComports() {
try {
const response = await window.axios.get(`/api/v1/comports`);
@ -1408,6 +1698,22 @@ export default {
this.sharedInterfaceSettings.network_name = iface.network_name;
this.sharedInterfaceSettings.passphrase = iface.passphrase;
this.sharedInterfaceSettings.ifac_size = iface.ifac_size;
// interface discovery
this.discovery.discoverable = this.parseBool(iface.discoverable);
this.discovery.discovery_name = iface.discovery_name ?? "";
this.discovery.announce_interval = iface.announce_interval ?? this.discovery.announce_interval;
this.discovery.reachable_on = iface.reachable_on ?? "";
this.discovery.discovery_stamp_value =
iface.discovery_stamp_value ?? this.discovery.discovery_stamp_value;
this.discovery.discovery_encrypt = this.parseBool(iface.discovery_encrypt);
this.discovery.publish_ifac = this.parseBool(iface.publish_ifac);
this.discovery.latitude = iface.latitude !== undefined ? Number(iface.latitude) : null;
this.discovery.longitude = iface.longitude !== undefined ? Number(iface.longitude) : null;
this.discovery.height = iface.height !== undefined ? Number(iface.height) : null;
this.discovery.discovery_frequency = iface.discovery_frequency ?? null;
this.discovery.discovery_bandwidth = iface.discovery_bandwidth ?? null;
this.discovery.discovery_modulation = iface.discovery_modulation ?? null;
} catch {
// do nothing if failed to load interfaces
}
@ -1430,6 +1736,15 @@ export default {
});
}
const discoveryEnabled = this.discovery.discoverable === true;
const isRadioInterface = ["RNodeInterface", "RNodeIPInterface"].includes(this.newInterfaceType);
const fallbackDiscoveryFrequency =
this.discovery.discovery_frequency ??
(discoveryEnabled && isRadioInterface ? this.calculateFrequencyInHz() : null);
const fallbackDiscoveryBandwidth =
this.discovery.discovery_bandwidth ??
(discoveryEnabled && isRadioInterface ? this.newInterfaceBandwidth : null);
// add interface
const response = await window.axios.post(`/api/v1/reticulum/interfaces/add`, {
allow_overwriting_interface: this.isEditingInterface,
@ -1506,6 +1821,32 @@ export default {
airtime_limit_long: this.newInterfaceAirtimeLimitLong,
airtime_limit_short: this.newInterfaceAirtimeLimitShort,
// discovery options
discoverable: discoveryEnabled ? "yes" : null,
discovery_name: discoveryEnabled ? this.discovery.discovery_name : null,
announce_interval:
discoveryEnabled && this.discovery.announce_interval !== null
? Number(this.discovery.announce_interval)
: null,
reachable_on: discoveryEnabled ? this.discovery.reachable_on : null,
discovery_stamp_value:
discoveryEnabled && this.discovery.discovery_stamp_value !== null
? Number(this.discovery.discovery_stamp_value)
: null,
discovery_encrypt: discoveryEnabled ? this.discovery.discovery_encrypt : null,
publish_ifac: discoveryEnabled ? this.discovery.publish_ifac : null,
latitude:
discoveryEnabled && this.discovery.latitude !== null ? Number(this.discovery.latitude) : null,
longitude:
discoveryEnabled && this.discovery.longitude !== null ? Number(this.discovery.longitude) : null,
height: discoveryEnabled && this.discovery.height !== null ? Number(this.discovery.height) : null,
discovery_frequency: discoveryEnabled ? fallbackDiscoveryFrequency : null,
discovery_bandwidth: discoveryEnabled ? fallbackDiscoveryBandwidth : null,
discovery_modulation:
discoveryEnabled && this.discovery.discovery_modulation
? this.discovery.discovery_modulation
: null,
// settings that can be added to any interface type
mode: this.sharedInterfaceSettings.mode || "full",
bitrate: this.sharedInterfaceSettings.bitrate,

View file

@ -42,6 +42,7 @@
<span :class="statusChipClass">{{
isInterfaceEnabled(iface) ? $t("app.enabled") : $t("app.disabled")
}}</span>
<span v-if="isDiscoverable()" class="discoverable-chip">Discoverable</span>
</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
{{ description }}
@ -244,6 +245,13 @@ export default {
onIFACSignatureClick: function (ifacSignature) {
DialogUtils.alert(ifacSignature);
},
isDiscoverable() {
const value = this.iface.discoverable;
if (typeof value === "string") {
return ["true", "yes", "1", "on"].includes(value.toLowerCase());
}
return Boolean(value);
},
isInterfaceEnabled: function (iface) {
return Utils.isInterfaceEnabled(iface);
},
@ -292,6 +300,9 @@ export default {
.ifac-line {
@apply text-xs flex flex-wrap items-center gap-1;
}
.discoverable-chip {
@apply inline-flex items-center rounded-full bg-blue-100 text-blue-700 px-2 py-0.5 text-xs font-semibold dark:bg-blue-900/50 dark:text-blue-200;
}
.detail-grid {
@apply grid gap-3 sm:grid-cols-2;
}

View file

@ -120,7 +120,115 @@
<div class="text-sm">{{ $t("interfaces.no_interfaces_description") }}</div>
</div>
<div v-else class="grid gap-4 xl:grid-cols-2">
<div class="glass-card space-y-4">
<div class="flex flex-wrap gap-3 items-center">
<div class="flex-1">
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
Discovery
</div>
<div class="text-xl font-semibold text-gray-900 dark:text-white">Interface Discovery</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
Publish your interfaces for others to find, or listen for announced entrypoints and
auto-connect to them.
</div>
</div>
<RouterLink :to="{ name: 'interfaces.add' }" class="secondary-chip text-sm">
<MaterialDesignIcon icon-name="lan" class="w-4 h-4" />
Configure Per-Interface
</RouterLink>
</div>
<div class="grid gap-4 md:grid-cols-2">
<div class="space-y-2 text-sm text-gray-700 dark:text-gray-300">
<div class="font-semibold text-gray-900 dark:text-white">Publish (Server)</div>
<div>
Enable discovery while adding or editing an interface to broadcast reachable details.
Reticulum will sign and stamp announces automatically.
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
Requires LXMF in the Python environment. Transport is optional for publishing, but
usually recommended so peers can connect back.
</div>
</div>
<div class="space-y-3">
<div class="flex items-center">
<div class="flex flex-col mr-auto">
<div class="text-sm font-semibold text-gray-900 dark:text-white">
Discover Interfaces (Peer)
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
Listen for discovery announces and optionally auto-connect to available
interfaces.
</div>
</div>
<Toggle v-model="discoveryConfig.discover_interfaces" class="my-auto mx-2" />
</div>
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<div>
<div class="text-xs font-semibold text-gray-700 dark:text-gray-200">
Allowed Sources
</div>
<input
v-model="discoveryConfig.interface_discovery_sources"
type="text"
placeholder="Comma separated identity hashes"
class="input-field"
/>
</div>
<div>
<div class="text-xs font-semibold text-gray-700 dark:text-gray-200">
Required Stamp Value
</div>
<input
v-model.number="discoveryConfig.required_discovery_value"
type="number"
min="0"
class="input-field"
/>
</div>
<div>
<div class="text-xs font-semibold text-gray-700 dark:text-gray-200">
Auto-connect Slots
</div>
<input
v-model.number="discoveryConfig.autoconnect_discovered_interfaces"
type="number"
min="0"
class="input-field"
/>
<div class="text-xs text-gray-500 dark:text-gray-400">0 disables auto-connect.</div>
</div>
<div>
<div class="text-xs font-semibold text-gray-700 dark:text-gray-200">
Network Identity Path
</div>
<input
v-model="discoveryConfig.network_identity"
type="text"
placeholder="~/.reticulum/storage/identities/..."
class="input-field"
/>
</div>
</div>
<div class="flex justify-end">
<button
type="button"
class="primary-chip text-xs"
:disabled="savingDiscovery"
@click="saveDiscoveryConfig"
>
<MaterialDesignIcon
:icon-name="savingDiscovery ? 'progress-clock' : 'content-save'"
class="w-4 h-4"
:class="{ 'animate-spin-reverse': savingDiscovery }"
/>
<span class="ml-1">Save Discovery Settings</span>
</button>
</div>
</div>
</div>
</div>
<div v-if="filteredInterfaces.length !== 0" class="grid gap-4 xl:grid-cols-2">
<Interface
v-for="iface of filteredInterfaces"
:key="iface._name"
@ -150,10 +258,12 @@ import DownloadUtils from "../../js/DownloadUtils";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import ToastUtils from "../../js/ToastUtils";
import GlobalState from "../../js/GlobalState";
import Toggle from "../forms/Toggle.vue";
export default {
name: "InterfacesPage",
components: {
Toggle,
ImportInterfacesModal,
Interface,
MaterialDesignIcon,
@ -168,6 +278,14 @@ export default {
typeFilter: "all",
reloadingRns: false,
isReticulumRunning: true,
discoveryConfig: {
discover_interfaces: false,
interface_discovery_sources: "",
required_discovery_value: null,
autoconnect_discovered_interfaces: 0,
network_identity: "",
},
savingDiscovery: false,
};
},
computed: {
@ -246,6 +364,7 @@ export default {
mounted() {
this.loadInterfaces();
this.updateInterfaceStats();
this.loadDiscoveryConfig();
// update info every few seconds
this.reloadInterval = setInterval(() => {
@ -387,6 +506,65 @@ export default {
this.trackInterfaceChange();
}
},
parseBool(value) {
if (typeof value === "string") {
return ["true", "yes", "1", "y", "on"].includes(value.toLowerCase());
}
return Boolean(value);
},
async loadDiscoveryConfig() {
try {
const response = await window.axios.get(`/api/v1/reticulum/discovery`);
const discovery = response.data?.discovery ?? {};
this.discoveryConfig.discover_interfaces = this.parseBool(discovery.discover_interfaces);
this.discoveryConfig.interface_discovery_sources = discovery.interface_discovery_sources ?? "";
this.discoveryConfig.required_discovery_value =
discovery.required_discovery_value !== undefined &&
discovery.required_discovery_value !== null &&
discovery.required_discovery_value !== ""
? Number(discovery.required_discovery_value)
: null;
this.discoveryConfig.autoconnect_discovered_interfaces =
discovery.autoconnect_discovered_interfaces !== undefined &&
discovery.autoconnect_discovered_interfaces !== null &&
discovery.autoconnect_discovered_interfaces !== ""
? Number(discovery.autoconnect_discovered_interfaces)
: 0;
this.discoveryConfig.network_identity = discovery.network_identity ?? "";
} catch (e) {
console.log(e);
}
},
async saveDiscoveryConfig() {
if (this.savingDiscovery) return;
this.savingDiscovery = true;
try {
const payload = {
discover_interfaces: this.discoveryConfig.discover_interfaces,
interface_discovery_sources: this.discoveryConfig.interface_discovery_sources || null,
required_discovery_value:
this.discoveryConfig.required_discovery_value === null ||
this.discoveryConfig.required_discovery_value === ""
? null
: Number(this.discoveryConfig.required_discovery_value),
autoconnect_discovered_interfaces:
this.discoveryConfig.autoconnect_discovered_interfaces === null ||
this.discoveryConfig.autoconnect_discovered_interfaces === ""
? 0
: Number(this.discoveryConfig.autoconnect_discovered_interfaces),
network_identity: this.discoveryConfig.network_identity || null,
};
await window.axios.patch(`/api/v1/reticulum/discovery`, payload);
ToastUtils.success("Discovery settings saved");
await this.loadDiscoveryConfig();
} catch (e) {
ToastUtils.error("Failed to save discovery settings");
console.log(e);
} finally {
this.savingDiscovery = false;
}
},
setStatusFilter(value) {
this.statusFilter = value;
},

View file

@ -116,6 +116,22 @@
>
<v-icon icon="mdi-trash-can-outline" size="18" class="sm:!size-5"></v-icon>
</button>
<button
v-if="selectedFeature"
class="p-1.5 sm:p-2 rounded-xl bg-blue-100 dark:bg-blue-900/30 text-blue-600 transition-all hover:scale-110 active:scale-90"
title="Edit note"
@click="startEditingNote(selectedFeature)"
>
<v-icon icon="mdi-note-edit-outline" size="18" class="sm:!size-5"></v-icon>
</button>
<button
v-if="selectedFeature && !selectedFeature.get('telemetry')"
class="p-1.5 sm:p-2 rounded-xl bg-red-100 dark:bg-red-900/30 text-red-600 transition-all hover:scale-110 active:scale-90 animate-pulse"
title="Delete selected item"
@click="deleteSelectedFeature"
>
<v-icon icon="mdi-selection-remove" size="18" class="sm:!size-5"></v-icon>
</button>
<div class="w-px h-6 bg-gray-200 dark:bg-zinc-800 my-auto mx-0.5 sm:mx-1"></div>
<button
class="p-1.5 sm:p-2 rounded-xl hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-600 dark:text-gray-400 transition-all hover:scale-110 active:scale-90"
@ -211,18 +227,27 @@
<!-- note hover tooltip -->
<div
v-if="hoveredNote && !editingFeature"
v-if="
hoveredFeature &&
(hoveredFeature.get('note') ||
(hoveredFeature.get('telemetry') && hoveredFeature.get('telemetry').note)) &&
!editingFeature
"
class="absolute pointer-events-none z-50 bg-white/90 dark:bg-zinc-900/90 backdrop-blur border border-gray-200 dark:border-zinc-700 rounded-lg shadow-xl p-2 text-sm text-gray-900 dark:text-zinc-100 max-w-xs transform -translate-x-1/2 -translate-y-full mb-4"
:style="{
left: map.getPixelFromCoordinate(hoveredNote.getGeometry().getCoordinates())[0] + 'px',
top: map.getPixelFromCoordinate(hoveredNote.getGeometry().getCoordinates())[1] + 'px',
left: map.getPixelFromCoordinate(hoveredFeature.getGeometry().getCoordinates())[0] + 'px',
top: map.getPixelFromCoordinate(hoveredFeature.getGeometry().getCoordinates())[1] + 'px',
}"
>
<div class="font-bold flex items-center gap-1 mb-1 text-amber-500">
<MaterialDesignIcon icon-name="note-text" class="size-4" />
<span>Note</span>
<span>{{
hoveredFeature.get("telemetry") ? hoveredFeature.get("peer")?.display_name || "Peer" : "Note"
}}</span>
</div>
<div class="whitespace-pre-wrap break-words">
{{ hoveredFeature.get("note") || hoveredFeature.get("telemetry")?.note }}
</div>
<div class="whitespace-pre-wrap break-words">{{ hoveredNote.get("note") || "Empty note" }}</div>
</div>
<!-- inline note editor (overlay) -->
@ -266,6 +291,58 @@
</div>
</div>
<!-- context menu -->
<div
v-if="showContextMenu"
class="fixed z-[120] bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-xl shadow-2xl overflow-hidden text-sm text-gray-900 dark:text-zinc-100"
:style="{ left: contextMenuPos.x + 'px', top: contextMenuPos.y + 'px' }"
>
<div class="px-3 py-2 font-bold border-b border-gray-100 dark:border-zinc-800">
{{ contextMenuFeature ? "Feature actions" : "Map actions" }}
</div>
<div class="flex flex-col">
<button
v-if="contextMenuFeature"
class="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 dark:hover:bg-zinc-800 text-left"
@click="contextSelectFeature"
>
<MaterialDesignIcon icon-name="cursor-default" class="size-4" />
<span>Select / Move</span>
</button>
<button
v-if="contextMenuFeature"
class="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 dark:hover:bg-zinc-800 text-left"
@click="contextAddNote"
>
<MaterialDesignIcon icon-name="note-edit" class="size-4" />
<span>Add / Edit Note</span>
</button>
<button
v-if="contextMenuFeature && !contextMenuFeature.get('telemetry')"
class="flex items-center gap-2 px-3 py-2 hover:bg-red-50 dark:hover:bg-red-900/20 text-left text-red-600"
@click="contextDeleteFeature"
>
<MaterialDesignIcon icon-name="delete" class="size-4" />
<span>Delete</span>
</button>
<button
class="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 dark:hover:bg-zinc-800 text-left"
@click="contextCopyCoords"
>
<MaterialDesignIcon icon-name="crosshairs-gps" class="size-4" />
<span>Copy coords</span>
</button>
<button
v-if="!contextMenuFeature"
class="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 dark:hover:bg-zinc-800 text-left"
@click="contextClearMap"
>
<MaterialDesignIcon icon-name="delete-sweep" class="size-4" />
<span>Clear drawings</span>
</button>
</div>
</div>
<!-- loading skeleton for map -->
<div v-if="!isMapLoaded" class="absolute inset-0 z-0 bg-slate-100 dark:bg-zinc-900 animate-pulse">
<div class="grid grid-cols-4 grid-rows-4 h-full w-full gap-1 p-1 opacity-20">
@ -550,11 +627,11 @@
>
<div class="flex justify-between space-x-4">
<span class="opacity-50 uppercase tracking-tighter">Lat</span>
<span class="text-gray-900 dark:text-zinc-100">{{ currentCenter[1].toFixed(6) }}</span>
<span class="text-gray-900 dark:text-zinc-100">{{ displayCoords[1].toFixed(6) }}</span>
</div>
<div class="flex justify-between space-x-4">
<span class="opacity-50 uppercase tracking-tighter">Lon</span>
<span class="text-gray-900 dark:text-zinc-100">{{ currentCenter[0].toFixed(6) }}</span>
<span class="text-gray-900 dark:text-zinc-100">{{ displayCoords[0].toFixed(6) }}</span>
</div>
</div>
</div>
@ -710,11 +787,11 @@
</div>
<div class="flex justify-between">
<span>Lat:</span>
<span class="font-mono">{{ currentCenter[1].toFixed(5) }}</span>
<span class="font-mono">{{ displayCoords[1].toFixed(5) }}</span>
</div>
<div class="flex justify-between">
<span>Lon:</span>
<span class="font-mono">{{ currentCenter[0].toFixed(5) }}</span>
<span class="font-mono">{{ displayCoords[0].toFixed(5) }}</span>
</div>
</div>
</div>
@ -967,15 +1044,18 @@ import XYZ from "ol/source/XYZ";
import VectorSource from "ol/source/Vector";
import Feature from "ol/Feature";
import Point from "ol/geom/Point";
import { Style, Text, Fill, Stroke, Circle as CircleStyle } from "ol/style";
import { Style, Text, Fill, Stroke, Circle as CircleStyle, Icon } from "ol/style";
import { fromLonLat, toLonLat } from "ol/proj";
import { defaults as defaultControls } from "ol/control";
import DragBox from "ol/interaction/DragBox";
import Draw from "ol/interaction/Draw";
import Modify from "ol/interaction/Modify";
import Snap from "ol/interaction/Snap";
import Select from "ol/interaction/Select";
import Translate from "ol/interaction/Translate";
import { getArea, getLength } from "ol/sphere";
import { LineString, Polygon } from "ol/geom";
import { LineString, Polygon, Circle } from "ol/geom";
import { fromCircle } from "ol/geom/Polygon";
import { unByKey } from "ol/Observable";
import Overlay from "ol/Overlay";
import GeoJSON from "ol/format/GeoJSON";
@ -1001,6 +1081,7 @@ export default {
isSettingsOpen: false,
currentCenter: [0, 0],
currentZoom: 2,
cursorCoords: null,
config: null,
peers: {},
@ -1059,8 +1140,8 @@ export default {
drawType: null, // 'Point', 'LineString', 'Polygon', 'Circle' or null
isDrawing: false,
drawingTools: [
{ type: "Select", icon: "cursor-default" },
{ type: "Point", icon: "map-marker-plus" },
{ type: "Note", icon: "note-text-outline" },
{ type: "LineString", icon: "vector-line" },
{ type: "Polygon", icon: "vector-polygon" },
{ type: "Circle", icon: "circle-outline" },
@ -1074,6 +1155,7 @@ export default {
helpTooltip: null,
measureTooltipElement: null,
measureTooltip: null,
measurementOverlays: [],
// drawing storage
savedDrawings: [],
@ -1081,13 +1163,22 @@ export default {
// note editing
editingFeature: null,
noteText: "",
hoveredNote: null,
hoveredFeature: null,
noteOverlay: null,
showNoteModal: false,
showSaveDrawingModal: false,
newDrawingName: "",
isLoadingDrawings: false,
showLoadDrawingModal: false,
styleCache: {},
selectedFeature: null,
select: null,
translate: null,
// context menu
showContextMenu: false,
contextMenuPos: { x: 0, y: 0 },
contextMenuFeature: null,
contextMenuCoord: null,
};
},
computed: {
@ -1104,6 +1195,9 @@ export default {
}
return total;
},
displayCoords() {
return this.cursorCoords || this.currentCenter;
},
},
watch: {
showSaveDrawingModal(val) {
@ -1148,7 +1242,9 @@ export default {
dataProjection: "EPSG:4326",
featureProjection: "EPSG:3857",
});
console.log("Restoring persisted drawings, count:", features.length);
this.drawSource.addFeatures(features);
this.rebuildMeasurementOverlays();
} catch (e) {
console.error("Failed to restore persisted drawings", e);
}
@ -1201,6 +1297,19 @@ export default {
}, 30000);
},
beforeUnmount() {
if (this.map && this.map.getViewport()) {
this.map.getViewport().removeEventListener("contextmenu", this.onContextMenu);
}
document.removeEventListener("click", this.handleGlobalClick);
if (this._saveStateTimer) {
clearTimeout(this._saveStateTimer);
this._saveStateTimer = null;
}
if (this._pendingSaveResolvers && this._pendingSaveResolvers.length > 0) {
const pending = this._pendingSaveResolvers.slice();
this._pendingSaveResolvers = [];
this.saveMapStateImmediate().then(() => pending.forEach((p) => p.resolve()));
}
if (this.reloadInterval) clearInterval(this.reloadInterval);
if (this.exportInterval) clearInterval(this.exportInterval);
if (this.searchTimeout) clearTimeout(this.searchTimeout);
@ -1209,17 +1318,37 @@ export default {
WebSocketConnection.off("message", this.onWebsocketMessage);
},
methods: {
async saveMapState() {
saveMapState() {
if (!this._pendingSaveResolvers) {
this._pendingSaveResolvers = [];
}
return new Promise((resolve, reject) => {
this._pendingSaveResolvers.push({ resolve, reject });
if (this._saveStateTimer) clearTimeout(this._saveStateTimer);
this._saveStateTimer = setTimeout(async () => {
const pending = this._pendingSaveResolvers.slice();
this._pendingSaveResolvers = [];
this._saveStateTimer = null;
try {
await this.saveMapStateImmediate();
pending.forEach((p) => p.resolve());
} catch (e) {
pending.forEach((p) => p.reject(e));
}
}, 150);
});
},
async saveMapStateImmediate() {
try {
// Serialize drawings
let drawings = null;
if (this.drawSource) {
const format = new GeoJSON();
drawings = format.writeFeatures(this.drawSource.getFeatures());
const features = this.serializeFeatures(this.drawSource.getFeatures());
drawings = format.writeFeatures(features, {
dataProjection: "EPSG:4326",
featureProjection: "EPSG:3857",
});
}
// Use JSON.parse/stringify to strip Vue Proxies and ensure plain objects/arrays
// This prevents DataCloneError when saving to IndexedDB
const state = JSON.parse(
JSON.stringify({
center: this.currentCenter,
@ -1231,6 +1360,7 @@ export default {
})
);
await TileCache.setMapState("last_view", state);
console.log("Map state persisted to cache, drawings size:", drawings ? drawings.length : 0);
} catch (e) {
console.error("Failed to save map state", e);
}
@ -1340,20 +1470,19 @@ export default {
source: this.drawSource,
style: (feature) => {
const type = feature.get("type");
if (type === "note") {
return new Style({
image: new CircleStyle({
radius: 10,
fill: new Fill({
color: "#f59e0b",
}),
stroke: new Stroke({
color: "#ffffff",
width: 2,
}),
}),
// Use a simple circle for now as custom fonts in canvas can be tricky
// or use the built-in Text style if we are sure it works
const geometry = feature.getGeometry();
const geomType = geometry ? geometry.getType() : null;
if (type === "note" || geomType === "Point") {
const isNote = type === "note";
return this.createMarkerStyle({
iconColor: isNote ? "#f59e0b" : "#3b82f6",
bgColor: "#ffffff",
label: isNote && feature.get("note") ? "Note" : "",
isStale: false,
iconPath: isNote
? "M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zM6 20V4h7v5h5v11H6z"
: null,
});
}
return new Style({
@ -1375,6 +1504,7 @@ export default {
zIndex: 50,
});
this.map.addLayer(this.drawLayer);
this.attachDrawPersistence();
this.noteOverlay = new Overlay({
element: this.$refs.noteOverlayElement,
@ -1387,16 +1517,83 @@ export default {
this.map.addOverlay(this.noteOverlay);
this.modify = new Modify({ source: this.drawSource });
this.modify.on("modifyend", () => this.saveMapState());
this.modify.on("modifystart", (e) => {
const feats = (e.features && e.features.getArray()) || this.select.getFeatures().getArray();
feats.forEach((f) => this.clearMeasurementOverlay(f));
});
this.modify.on("modifyend", (e) => {
const feats = (e.features && e.features.getArray()) || this.select.getFeatures().getArray();
feats.forEach((f) => this.finalizeMeasurementOverlay(f));
this.saveMapState();
});
this.map.addInteraction(this.modify);
this.select = new Select({
layers: [this.drawLayer],
hitTolerance: 15, // High tolerance for touch/offgrid
style: null, // Keep original feature style
});
this.select.on("select", (e) => {
this.selectedFeature = e.selected[0] || null;
});
this.map.addInteraction(this.select);
this.translate = new Translate({
features: this.select.getFeatures(),
layers: [this.drawLayer], // Only move drawing layer items, not telemetry
});
this.translate.on("translateend", (e) => {
const feats = (e.features && e.features.getArray()) || this.select.getFeatures().getArray();
feats.forEach((f) => this.finalizeMeasurementOverlay(f));
this.saveMapState();
});
this.map.addInteraction(this.translate);
// Default to Select tool
this.drawType = "Select";
this.select.setActive(true);
this.translate.setActive(true);
this.modify.setActive(true);
this.snap = new Snap({ source: this.drawSource });
this.map.addInteraction(this.snap);
// Right-click context menu
this.map.getViewport().addEventListener("contextmenu", this.onContextMenu);
// setup telemetry markers
this.markerSource = new VectorSource();
this.markerLayer = new VectorLayer({
source: this.markerSource,
style: (feature) => {
const t = feature.get("telemetry");
const peer = feature.get("peer");
const displayName = peer?.display_name || t.destination_hash.substring(0, 8);
// Calculate staleness
const now = Date.now();
const updatedAt = t.updated_at
? new Date(t.updated_at).getTime()
: t.timestamp
? t.timestamp * 1000
: now;
const isStale = now - updatedAt > 10 * 60 * 1000;
let iconColor = "#2563eb";
let bgColor = "#ffffff";
if (peer?.lxmf_user_icon) {
iconColor = peer.lxmf_user_icon.foreground_colour || iconColor;
bgColor = peer.lxmf_user_icon.background_colour || bgColor;
}
return this.createMarkerStyle({
iconColor,
bgColor,
label: displayName,
isStale,
});
},
zIndex: 100,
});
this.map.addLayer(this.markerLayer);
@ -1404,12 +1601,19 @@ export default {
this.map.on("pointermove", this.handleMapPointerMove);
this.map.on("click", (evt) => {
this.handleMapClick(evt);
this.closeContextMenu();
const feature = this.map.forEachFeatureAtPixel(evt.pixel, (f) => f);
if (feature && feature.get("telemetry")) {
this.onMarkerClick(feature);
} else {
this.selectedMarker = null;
}
// Deselect drawing if clicking empty space
if (!feature && this.select) {
this.select.getFeatures().clear();
this.selectedFeature = null;
}
});
this.currentCenter = [defaultLon, defaultLat];
@ -1431,6 +1635,9 @@ export default {
this.map.addInteraction(this.dragBox);
this.isMapLoaded = true;
// Close context menu when clicking elsewhere
document.addEventListener("click", this.handleGlobalClick);
},
isLocalUrl(url) {
if (!url) return false;
@ -2057,10 +2264,29 @@ export default {
}
},
attachDrawPersistence() {
if (!this.drawSource) return;
const persist = () => this.saveMapState();
this.drawSource.on("addfeature", persist);
this.drawSource.on("removefeature", persist);
this.drawSource.on("changefeature", persist);
this.drawSource.on("clear", persist);
},
deleteSelectedFeature() {
if (this.selectedFeature && this.drawSource) {
this.clearMeasurementOverlay(this.selectedFeature);
this.drawSource.removeFeature(this.selectedFeature);
if (this.select) this.select.getFeatures().clear();
this.selectedFeature = null;
this.saveMapState();
}
},
// Drawing methods
toggleDraw(type) {
if (!this.map) return;
if (this.drawType === type && !this.isMeasuring) {
if (this.drawType === type && !this.isDrawing) {
this.stopDrawing();
return;
}
@ -2069,27 +2295,79 @@ export default {
this.isMeasuring = false;
this.drawType = type;
if (type === "Select") {
if (this.select) this.select.setActive(true);
if (this.translate) this.translate.setActive(true);
if (this.modify) this.modify.setActive(true);
return;
}
// Disable selection/translation while drawing
if (this.select) this.select.setActive(false);
if (this.translate) this.translate.setActive(false);
if (this.modify) this.modify.setActive(false);
this.draw = new Draw({
source: this.drawSource,
type: type === "Note" ? "Point" : type,
type: type,
});
this.draw.on("drawstart", () => {
this.draw.on("drawstart", (evt) => {
this.isDrawing = true;
this.sketch = evt.feature;
// For LineString, Polygon, and Circle, show measure tooltip while drawing
if (type === "LineString" || type === "Polygon" || type === "Circle") {
this.createMeasureTooltip();
this._drawListener = this.sketch.getGeometry().on("change", (e) => {
const geom = e.target;
let output;
let tooltipCoord;
if (geom instanceof Polygon) {
output = this.formatArea(geom);
tooltipCoord = geom.getInteriorPoint().getCoordinates();
} else if (geom instanceof LineString) {
output = this.formatLength(geom);
tooltipCoord = geom.getLastCoordinate();
} else if (geom instanceof Circle) {
const radius = geom.getRadius();
const center = geom.getCenter();
// Calculate radius distance in projection (sphere-aware)
const edge = [center[0] + radius, center[1]];
const line = new LineString([center, edge]);
output = `Radius: ${this.formatLength(line)}`;
tooltipCoord = edge;
}
if (output) {
this.measureTooltipElement.innerHTML = output;
this.measureTooltip.setPosition(tooltipCoord);
}
});
}
});
this.draw.on("drawend", (evt) => {
this.isDrawing = false;
const feature = evt.feature;
if (type === "Note") {
feature.set("type", "note");
feature.set("note", "");
// Open edit box after a short delay to let the feature settle
setTimeout(() => {
this.startEditingNote(feature);
}, 200);
feature.set("type", "draw"); // Tag as custom drawing for styling
// Clean up sketch listener and tooltips unless it was the Measure tool
if (this._drawListener) {
unByKey(this._drawListener);
this._drawListener = null;
}
// Use setTimeout to ensure the feature is actually in the source before saving
this.sketch = null;
// Finalize measurement overlay for the drawn feature
this.finalizeMeasurementOverlay(feature);
this.cleanupMeasureTooltip();
// Re-enable select/translate/modify after drawing
if (this.select) this.select.setActive(true);
if (this.translate) this.translate.setActive(true);
if (this.modify) this.modify.setActive(true);
this.drawType = "Select";
setTimeout(() => this.saveMapState(), 100);
});
@ -2098,7 +2376,8 @@ export default {
startEditingNote(feature) {
this.editingFeature = feature;
this.noteText = feature.get("note") || "";
const telemetry = feature.get("telemetry");
this.noteText = telemetry ? telemetry.note || "" : feature.get("note") || "";
if (this.isMobileScreen) {
this.showNoteModal = true;
} else {
@ -2109,13 +2388,29 @@ export default {
updateNoteOverlay() {
if (!this.editingFeature || !this.map) return;
const geometry = this.editingFeature.getGeometry();
const coord = geometry.getCoordinates();
let coord;
if (geometry instanceof Point) {
coord = geometry.getCoordinates();
} else if (geometry instanceof LineString) {
coord = geometry.getCoordinateAt(0.5); // Middle of line
} else if (geometry instanceof Polygon) {
coord = geometry.getInteriorPoint().getCoordinates();
} else if (geometry instanceof Circle) {
coord = geometry.getCenter();
} else {
coord = this.map.getView().getCenter();
}
this.noteOverlay.setPosition(coord);
},
saveNote() {
if (this.editingFeature) {
this.editingFeature.set("note", this.noteText);
const telemetry = this.editingFeature.get("telemetry");
if (telemetry) {
telemetry.note = this.noteText;
} else {
this.editingFeature.set("note", this.noteText);
}
this.saveMapState();
}
this.closeNoteEditor();
@ -2144,19 +2439,190 @@ export default {
this.closeNoteEditor();
},
// Measurement helpers
cleanupMeasureTooltip() {
if (this.measureTooltipElement && this.measureTooltipElement.parentNode) {
this.measureTooltipElement.parentNode.removeChild(this.measureTooltipElement);
}
if (this.measureTooltip) {
this.map.removeOverlay(this.measureTooltip);
}
this.measureTooltipElement = null;
this.measureTooltip = null;
},
getMeasurementForGeometry(geom) {
if (geom instanceof Polygon) {
return {
text: this.formatArea(geom),
coord: geom.getInteriorPoint().getCoordinates(),
};
}
if (geom instanceof LineString) {
return {
text: this.formatLength(geom),
coord: geom.getLastCoordinate(),
};
}
if (geom instanceof Circle) {
const center = geom.getCenter();
const edge = [center[0] + geom.getRadius(), center[1]];
const line = new LineString([center, edge]);
return {
text: `Radius: ${this.formatLength(line)}`,
coord: edge,
};
}
return null;
},
clearMeasurementOverlay(feature) {
const overlay = feature.get("_measureOverlay");
if (overlay) {
this.map.removeOverlay(overlay);
feature.unset("_measureOverlay", true);
}
},
finalizeMeasurementOverlay(feature) {
if (!this.map) return;
this.clearMeasurementOverlay(feature);
const geom = feature.getGeometry();
const measurement = this.getMeasurementForGeometry(geom);
if (!measurement) return;
const el = document.createElement("div");
el.className = "ol-tooltip ol-tooltip-static";
el.innerHTML = measurement.text;
const overlay = new Overlay({
element: el,
offset: [0, -7],
positioning: "bottom-center",
});
overlay.set("isMeasureTooltip", true);
this.map.addOverlay(overlay);
overlay.setPosition(measurement.coord);
feature.set("_measureOverlay", overlay);
},
rebuildMeasurementOverlays() {
if (!this.drawSource || !this.map) return;
// Remove all existing measure overlays
const overlays = this.map.getOverlays().getArray();
for (let i = overlays.length - 1; i >= 0; i--) {
const ov = overlays[i];
if (ov.get && ov.get("isMeasureTooltip")) {
this.map.removeOverlay(ov);
}
}
// Rebuild for all features
this.drawSource.getFeatures().forEach((f) => {
f.unset("_measureOverlay", true);
this.finalizeMeasurementOverlay(f);
});
},
serializeFeatures(features) {
return features.map((f) => {
const clone = f.clone();
clone.unset("_measureOverlay", true); // avoid circular refs
const geom = clone.getGeometry();
if (geom instanceof Circle) {
clone.setGeometry(fromCircle(geom, 128));
}
return clone;
});
},
// Context menu handlers
onContextMenu(evt) {
if (!this.map) return;
evt.preventDefault();
const pixel = this.map.getEventPixel(evt);
const feature = this.map.forEachFeatureAtPixel(pixel, (f) => f);
this.contextMenuFeature = feature || null;
this.contextMenuCoord = toLonLat(this.map.getCoordinateFromPixel(pixel));
this.contextMenuPos = { x: evt.clientX, y: evt.clientY };
if (feature && this.select) {
this.select.getFeatures().clear();
this.select.getFeatures().push(feature);
this.selectedFeature = feature;
}
this.showContextMenu = true;
},
closeContextMenu() {
this.showContextMenu = false;
},
contextSelectFeature() {
if (!this.contextMenuFeature || !this.select || !this.translate) {
this.closeContextMenu();
return;
}
this.select.setActive(true);
this.translate.setActive(true);
this.modify?.setActive(true);
this.select.getFeatures().clear();
this.select.getFeatures().push(this.contextMenuFeature);
this.selectedFeature = this.contextMenuFeature;
this.drawType = "Select";
this.closeContextMenu();
},
contextDeleteFeature() {
if (this.contextMenuFeature && !this.contextMenuFeature.get("telemetry")) {
this.drawSource.removeFeature(this.contextMenuFeature);
this.saveMapState();
}
this.closeContextMenu();
},
contextAddNote() {
if (this.contextMenuFeature) {
this.startEditingNote(this.contextMenuFeature);
}
this.closeContextMenu();
},
async contextCopyCoords() {
if (!this.contextMenuCoord) {
this.closeContextMenu();
return;
}
const [lon, lat] = this.contextMenuCoord;
const text = `${lat.toFixed(6)}, ${lon.toFixed(6)}`;
try {
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
ToastUtils.success("Copied coordinates");
} else {
ToastUtils.success(text);
}
} catch (e) {
console.error("Copy failed", e);
ToastUtils.warning(text);
}
this.closeContextMenu();
},
contextClearMap() {
this.clearDrawings();
this.closeContextMenu();
},
// Clear all overlays on escape/context close
handleGlobalClick() {
if (this.showContextMenu) {
this.closeContextMenu();
}
},
handleMapPointerMove(evt) {
if (!this.map) return;
const lonLat = toLonLat(evt.coordinate);
this.cursorCoords = [lonLat[0], lonLat[1]];
if (evt.dragging || this.isDrawing || this.isMeasuring) return;
const pixel = this.map.getEventPixel(evt.originalEvent);
const feature = this.map.forEachFeatureAtPixel(pixel, (f) => f, {
layerFilter: (l) => l === this.drawLayer,
});
const feature = this.map.forEachFeatureAtPixel(pixel, (f) => f);
if (feature && feature.get("type") === "note") {
this.hoveredNote = feature;
if (feature) {
const hasNote = feature.get("note") || (feature.get("telemetry") && feature.get("telemetry").note);
if (hasNote) {
this.hoveredFeature = feature;
} else {
this.hoveredFeature = null;
}
this.map.getTargetElement().style.cursor = "pointer";
} else {
this.hoveredNote = null;
this.hoveredFeature = null;
this.map.getTargetElement().style.cursor = "";
}
},
@ -2181,6 +2647,9 @@ export default {
this.map.removeInteraction(this.draw);
this.draw = null;
}
if (this.select) this.select.setActive(true);
if (this.translate) this.translate.setActive(true);
if (this.modify) this.modify.setActive(true);
this.drawType = null;
this.isDrawing = false;
this.stopMeasuring();
@ -2402,8 +2871,11 @@ export default {
}
const format = new GeoJSON();
const features = this.drawSource.getFeatures();
const json = format.writeFeatures(features);
const features = this.serializeFeatures(this.drawSource.getFeatures());
const json = format.writeFeatures(features, {
dataProjection: "EPSG:4326",
featureProjection: "EPSG:3857",
});
try {
await window.axios.post("/api/v1/map/drawings", {
@ -2426,6 +2898,7 @@ export default {
});
this.drawSource.clear();
this.drawSource.addFeatures(features);
await this.saveMapState();
this.showLoadDrawingModal = false;
ToastUtils.success(`Loaded "${drawing.name}"`);
},
@ -2493,44 +2966,47 @@ export default {
const loc = t.telemetry?.location;
if (!loc || loc.latitude === undefined || loc.longitude === undefined) continue;
const peer = this.peers[t.destination_hash];
const displayName = peer?.display_name || t.destination_hash.substring(0, 8);
const feature = new Feature({
geometry: new Point(fromLonLat([loc.longitude, loc.latitude])),
telemetry: t,
peer: peer,
peer: this.peers[t.destination_hash],
});
// Default style
let iconColor = "#3b82f6";
let bgColor = "#ffffff";
if (peer?.lxmf_user_icon) {
iconColor = peer.lxmf_user_icon.foreground_colour || iconColor;
bgColor = peer.lxmf_user_icon.background_colour || bgColor;
}
feature.setStyle(
new Style({
image: new CircleStyle({
radius: 8,
fill: new Fill({ color: bgColor }),
stroke: new Stroke({ color: iconColor, width: 2 }),
}),
text: new Text({
text: displayName,
offsetY: -15,
font: "bold 11px sans-serif",
fill: new Fill({ color: "#000" }),
stroke: new Stroke({ color: "#fff", width: 2 }),
}),
})
);
this.markerSource.addFeature(feature);
}
},
createMarkerStyle({ iconColor, bgColor, label, isStale, iconPath }) {
const cacheKey = `${iconColor}-${bgColor}-${label}-${isStale}-${iconPath || "default"}`;
if (this.styleCache[cacheKey]) return this.styleCache[cacheKey];
const markerFill = isStale ? "#d1d5db" : bgColor;
const markerStroke = isStale ? "#9ca3af" : iconColor;
const path =
iconPath ||
"M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7Zm0 11a2 2 0 1 1 0-4 2 2 0 0 1 0 4Z";
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="${path}" fill="${markerFill}" stroke="${markerStroke}" stroke-width="1.5"/></svg>`;
const src = "data:image/svg+xml;base64," + btoa(unescape(encodeURIComponent(svg)));
const style = new Style({
image: new Icon({
src: src,
anchor: [0.5, 1],
scale: 1.6, // Reduced from 2.5
imgSize: [24, 24],
}),
text: new Text({
text: label,
offsetY: -45, // Adjusted from -60
font: "bold 12px sans-serif",
fill: new Fill({ color: isStale ? "#6b7280" : "#111827" }),
stroke: new Stroke({ color: "#ffffff", width: 3 }),
}),
});
this.styleCache[cacheKey] = style;
return style;
},
onMarkerClick(feature) {
this.selectedMarker = {
telemetry: feature.get("telemetry"),

View file

@ -180,6 +180,18 @@
<div class="text-[10px] text-gray-500 dark:text-zinc-500 font-mono truncate">
{{ contact.remote_identity_hash }}
</div>
<div
v-if="contact.lxmf_address"
class="text-[9px] text-gray-400 dark:text-zinc-500 font-mono truncate"
>
LXMF: {{ contact.lxmf_address }}
</div>
<div
v-if="contact.lxst_address"
class="text-[9px] text-gray-400 dark:text-zinc-500 font-mono truncate"
>
LXST: {{ contact.lxst_address }}
</div>
</div>
</button>
</div>
@ -264,11 +276,17 @@
<span class="text-sm font-bold">Contact Shared</span>
</div>
<div class="flex items-center gap-3">
<div
class="size-10 flex items-center justify-center rounded-full bg-blue-100 dark:bg-blue-800 text-blue-600 dark:text-blue-200 font-bold"
>
{{ getParsedItems(chatItem).contact.name.charAt(0).toUpperCase() }}
</div>
<LxmfUserIcon
:custom-image="getParsedItems(chatItem).contact.custom_image"
:icon-name="getParsedItems(chatItem).contact.lxmf_user_icon?.icon_name"
:icon-foreground-colour="
getParsedItems(chatItem).contact.lxmf_user_icon?.foreground_colour
"
:icon-background-colour="
getParsedItems(chatItem).contact.lxmf_user_icon?.background_colour
"
icon-class="size-10"
/>
<div class="flex-1 min-w-0">
<div class="text-sm font-bold text-gray-900 dark:text-white truncate">
{{ getParsedItems(chatItem).contact.name }}
@ -278,6 +296,18 @@
>
{{ getParsedItems(chatItem).contact.hash }}
</div>
<div
v-if="getParsedItems(chatItem).contact.lxmf_address"
class="text-[9px] font-mono text-gray-400 dark:text-zinc-500 truncate"
>
LXMF: {{ getParsedItems(chatItem).contact.lxmf_address }}
</div>
<div
v-if="getParsedItems(chatItem).contact.lxst_address"
class="text-[9px] font-mono text-gray-400 dark:text-zinc-500 truncate"
>
LXST: {{ getParsedItems(chatItem).contact.lxst_address }}
</div>
</div>
</div>
<button
@ -286,7 +316,9 @@
@click="
addContact(
getParsedItems(chatItem).contact.name,
getParsedItems(chatItem).contact.hash
getParsedItems(chatItem).contact.hash,
getParsedItems(chatItem).contact.lxmf_address,
getParsedItems(chatItem).contact.lxst_address
)
"
>
@ -1858,12 +1890,30 @@ export default {
paperMessage: null,
};
// Parse contact: Contact: ivan <ca314c30b27eacec5f6ca6ac504e94c9>
const contactMatch = content.match(/^Contact:\s+(.+?)\s+<([a-fA-F0-9]{32})>$/i);
// Parse contact: Contact: ivan <ca314c30b27eacec5f6ca6ac504e94c9> [LXMF: ...] [LXST: ...]
const contactMatch = content.match(
/^Contact:\s+(.+?)\s+<([a-fA-F0-9]{32})>(?:\s+\[LXMF:\s+([a-fA-F0-9]{32})\])?(?:\s+\[LXST:\s+([a-fA-F0-9]{32})\])?/i
);
if (contactMatch) {
const contactHash = contactMatch[2];
const lxmfAddress = contactMatch[3];
const lxstAddress = contactMatch[4];
// try to find enriched info from existing conversations/peers
const existing = this.conversations.find(
(c) =>
c.destination_hash === contactHash ||
c.destination_hash === lxmfAddress ||
c.destination_hash === lxstAddress
);
items.contact = {
name: contactMatch[1],
hash: contactMatch[2],
hash: contactHash,
lxmf_address: lxmfAddress,
lxst_address: lxstAddress,
custom_image: existing?.contact_image,
lxmf_user_icon: existing?.lxmf_user_icon,
};
}
@ -1881,7 +1931,7 @@ export default {
return items;
},
async addContact(name, hash) {
async addContact(name, hash, lxmf_address = null, lxst_address = null) {
try {
// Check if contact already exists
const checkResponse = await window.axios.get(`/api/v1/telephone/contacts/check/${hash}`);
@ -1893,6 +1943,8 @@ export default {
await window.axios.post("/api/v1/telephone/contacts", {
name: name,
remote_identity_hash: hash,
lxmf_address: lxmf_address,
lxst_address: lxst_address,
});
ToastUtils.success(`Added ${name} to contacts`);
} catch (e) {
@ -2539,10 +2591,13 @@ export default {
ToastUtils.error("Failed to load contacts");
}
},
async shareContact(contact) {
this.newMessageText = `Contact: ${contact.name} <${contact.remote_identity_hash}>`;
shareContact(contact) {
let sharedString = `Contact: ${contact.name} <${contact.remote_identity_hash}>`;
if (contact.lxmf_address) sharedString += ` [LXMF: ${contact.lxmf_address}]`;
if (contact.lxst_address) sharedString += ` [LXST: ${contact.lxst_address}]`;
this.newMessageText = sharedString;
this.isShareContactModalOpen = false;
await this.sendMessage();
this.sendMessage();
},
shareAsPaperMessage(chatItem) {
this.paperMessageHash = chatItem.lxmf_message.hash;

View file

@ -192,6 +192,7 @@ export default {
// stop listening for websocket messages
WebSocketConnection.off("message", this.onWebsocketMessage);
GlobalEmitter.off("compose-new-message", this.onComposeNewMessage);
GlobalEmitter.off("refresh-conversations", this.requestConversationsRefresh);
},
mounted() {
// listen for websocket messages
@ -371,6 +372,20 @@ export default {
this.conversations = newConversations;
}
for (const conversation of newConversations) {
if (!conversation?.destination_hash) continue;
const existingPeer = this.peers[conversation.destination_hash] || {};
this.peers[conversation.destination_hash] = {
...existingPeer,
destination_hash: conversation.destination_hash,
display_name: conversation.display_name ?? existingPeer.display_name,
custom_display_name: conversation.custom_display_name ?? existingPeer.custom_display_name,
contact_image: conversation.contact_image ?? existingPeer.contact_image,
lxmf_user_icon: conversation.lxmf_user_icon ?? existingPeer.lxmf_user_icon,
updated_at: conversation.updated_at ?? existingPeer.updated_at,
};
}
this.hasLoadedConversations = true;
this.hasMoreConversations = newConversations.length === this.pageSize;
} catch (e) {
@ -402,7 +417,8 @@ export default {
return params;
},
updatePeerFromAnnounce: function (announce) {
this.peers[announce.destination_hash] = announce;
const existing = this.peers[announce.destination_hash] || {};
this.peers[announce.destination_hash] = { ...existing, ...announce };
},
onPeerClick: function (peer) {
// update selected peer

View file

@ -267,6 +267,7 @@
<div class="my-auto mr-2">
<LxmfUserIcon
:custom-image="peer.contact_image"
:icon-name="peer.lxmf_user_icon?.icon_name"
:icon-foreground-colour="peer.lxmf_user_icon?.foreground_colour"
:icon-background-colour="peer.lxmf_user_icon?.background_colour"
@ -457,6 +458,9 @@ export default {
return matchesDisplayName || matchesCustomDisplayName || matchesDestinationHash;
});
},
hasUnreadConversations() {
return this.conversations.some((c) => c.is_unread);
},
},
methods: {
isBlocked(destinationHash) {

View file

@ -212,6 +212,8 @@ import LxmfUserIcon from "../LxmfUserIcon.vue";
import ToastUtils from "../../js/ToastUtils";
import ColourPickerDropdown from "../ColourPickerDropdown.vue";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import GlobalState from "../../js/GlobalState";
import GlobalEmitter from "../../js/GlobalEmitter";
export default {
name: "ProfileIconPage",
@ -324,6 +326,8 @@ export default {
try {
const response = await window.axios.patch("/api/v1/config", config);
this.config = response.data.config;
GlobalState.config = response.data.config;
GlobalEmitter.emit("config-updated", response.data.config);
this.saveOriginalValues();
if (!silent) {

View file

@ -0,0 +1,326 @@
<template>
<div
class="flex flex-col flex-1 overflow-hidden min-w-0 bg-gradient-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900"
>
<div class="flex-1 overflow-y-auto w-full px-4 md:px-8 py-6">
<div class="space-y-4 w-full max-w-4xl mx-auto">
<div class="glass-card space-y-5">
<div class="space-y-2">
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
{{ $t("bots.bot_framework") }}
</div>
<div class="text-2xl font-semibold text-gray-900 dark:text-white">{{ $t("bots.title") }}</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
{{ $t("bots.description") }}
</div>
</div>
<div class="space-y-6">
<!-- Create New Bot -->
<div class="space-y-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ $t("bots.create_new_bot") }}
</h3>
<div class="grid sm:grid-cols-2 gap-4">
<div
v-for="template in templates"
:key="template.id"
class="glass-card !p-4 hover:border-blue-400 transition cursor-pointer flex flex-col justify-between"
@click="selectTemplate(template)"
>
<div>
<div class="font-bold text-gray-900 dark:text-white">{{ template.name }}</div>
<div class="text-sm text-gray-600 dark:text-gray-400 mt-1">
{{ template.description }}
</div>
</div>
<button
class="primary-chip w-full mt-4 py-2"
@click.stop="selectTemplate(template)"
>
{{ $t("bots.select") }}
</button>
</div>
<!-- More bots coming soon -->
<div
class="glass-card !p-4 border-dashed border-2 border-gray-200 dark:border-zinc-800 flex flex-col items-center justify-center opacity-60"
>
<div class="p-2 bg-gray-100 dark:bg-zinc-800 rounded-lg mb-2">
<MaterialDesignIcon
icon-name="plus"
class="size-6 text-gray-400 dark:text-gray-500"
/>
</div>
<div class="text-sm font-medium text-gray-500 dark:text-gray-400 text-center">
{{ $t("bots.more_bots_coming") }}
</div>
</div>
</div>
</div>
<!-- Saved Bots -->
<div class="space-y-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ $t("bots.saved_bots") }}
</h3>
<div v-if="bots.length === 0" class="text-sm text-gray-500 italic">
{{ $t("bots.no_bots_running") }}
</div>
<div v-else class="space-y-3">
<div
v-for="bot in bots"
:key="bot.id"
class="glass-card !p-4 flex items-center justify-between"
>
<div class="flex items-center gap-3">
<div class="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
<MaterialDesignIcon
icon-name="robot"
class="size-6 text-blue-600 dark:text-blue-400"
/>
</div>
<div>
<div class="font-bold text-gray-900 dark:text-white">{{ bot.name }}</div>
<div class="text-xs font-mono text-gray-500">
{{ runningMap[bot.id]?.address || "Not running" }}
</div>
<div class="text-[10px] text-gray-400">
{{ bot.template_id || bot.template }}
</div>
<div v-if="bot.storage_dir" class="text-[10px] text-gray-400">
{{ bot.storage_dir }}
</div>
</div>
</div>
<div class="flex items-center gap-2">
<template v-if="runningMap[bot.id]">
<button
class="p-2 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
:title="$t('bots.stop_bot')"
@click="stopBot(bot.id)"
>
<MaterialDesignIcon icon-name="stop" class="size-5" />
</button>
<button
class="p-2 text-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-colors"
:title="$t('bots.restart_bot')"
@click="restartExisting(bot)"
>
<MaterialDesignIcon icon-name="refresh" class="size-5" />
</button>
</template>
<template v-else>
<button class="primary-chip px-3 py-1 text-xs" @click="startExisting(bot)">
{{ $t("bots.start_bot") }}
</button>
</template>
<button
class="p-2 text-gray-500 hover:bg-gray-50 dark:hover:bg-zinc-800 rounded-lg transition-colors"
:title="$t('bots.export_identity')"
@click="exportIdentity(bot.id)"
>
<MaterialDesignIcon icon-name="export" class="size-5" />
</button>
<button
class="p-2 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
:title="$t('bots.delete_bot')"
@click="deleteBot(bot.id)"
>
<MaterialDesignIcon icon-name="delete" class="size-5" />
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Start Bot Modal -->
<div
v-if="selectedTemplate"
class="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"
@click.self="selectedTemplate = null"
>
<div class="glass-card max-w-md w-full space-y-4">
<div class="flex justify-between items-center">
<h3 class="text-xl font-bold text-gray-900 dark:text-white">
{{ $t("bots.start_bot") }}: {{ selectedTemplate.name }}
</h3>
<button
class="p-1 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-lg"
@click="selectedTemplate = null"
>
<MaterialDesignIcon icon-name="close" class="size-5" />
</button>
</div>
<div class="space-y-4">
<div>
<label class="glass-label">{{ $t("bots.bot_name") }}</label>
<input
v-model="newBotName"
type="text"
:placeholder="selectedTemplate.name"
class="input-field"
/>
</div>
<div class="text-sm text-gray-600 dark:text-gray-400">
{{ selectedTemplate.description }}
</div>
<div class="flex gap-3 justify-end pt-2">
<button class="secondary-chip px-6 py-2" @click="selectedTemplate = null">
{{ $t("bots.cancel") }}
</button>
<button class="primary-chip px-6 py-2" :disabled="isStarting" @click="startBot">
<span
v-if="isStarting"
class="inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin mr-2"
></span>
{{ $t("bots.start_bot") }}
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import ToastUtils from "../../js/ToastUtils";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
export default {
name: "BotsPage",
components: {
MaterialDesignIcon,
},
data() {
return {
bots: [],
templates: [],
runningBots: [],
selectedTemplate: null,
newBotName: "",
isStarting: false,
loading: true,
refreshInterval: null,
};
},
computed: {
runningMap() {
const map = {};
this.runningBots.forEach((b) => {
map[b.id] = b;
});
return map;
},
},
mounted() {
this.getStatus();
this.refreshInterval = setInterval(this.getStatus, 5000);
},
beforeUnmount() {
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
}
},
methods: {
async getStatus() {
try {
const response = await window.axios.get("/api/v1/bots/status");
this.bots = response.data.status.bots || [];
this.runningBots = response.data.status.running_bots;
this.templates = response.data.templates;
this.loading = false;
} catch (e) {
console.error(e);
}
},
selectTemplate(template) {
this.selectedTemplate = template;
this.newBotName = template.name;
},
async startBot() {
if (this.isStarting) return;
this.isStarting = true;
try {
await window.axios.post("/api/v1/bots/start", {
template_id: this.selectedTemplate.id,
name: this.newBotName,
});
ToastUtils.success(this.$t("bots.bot_started"));
this.selectedTemplate = null;
this.getStatus();
} catch (e) {
console.error(e);
ToastUtils.error(e.response?.data?.message || this.$t("bots.failed_to_start"));
} finally {
this.isStarting = false;
}
},
async stopBot(botId) {
try {
await window.axios.post("/api/v1/bots/stop", { bot_id: botId });
ToastUtils.success(this.$t("bots.bot_stopped"));
this.getStatus();
} catch (e) {
console.error(e);
ToastUtils.error(this.$t("bots.failed_to_stop"));
}
},
async startExisting(bot) {
try {
await window.axios.post("/api/v1/bots/start", {
bot_id: bot.id,
template_id: bot.template_id,
name: bot.name,
});
ToastUtils.success(this.$t("bots.bot_started"));
this.getStatus();
} catch (e) {
console.error(e);
ToastUtils.error(e.response?.data?.message || this.$t("bots.failed_to_start"));
}
},
async restartExisting(bot) {
try {
await window.axios.post("/api/v1/bots/restart", { bot_id: bot.id });
ToastUtils.success(this.$t("bots.bot_started"));
this.getStatus();
} catch (e) {
console.error(e);
ToastUtils.error(e.response?.data?.message || this.$t("bots.failed_to_start"));
}
},
async deleteBot(botId) {
if (!confirm(this.$t("common.delete_confirm"))) return;
try {
await window.axios.post("/api/v1/bots/delete", { bot_id: botId });
ToastUtils.success(this.$t("bots.bot_deleted"));
this.getStatus();
} catch (e) {
console.error(e);
ToastUtils.error(this.$t("bots.failed_to_delete"));
}
},
exportIdentity(botId) {
window.open(`/api/v1/bots/export?bot_id=${botId}`, "_blank");
},
copyToClipboard(text) {
navigator.clipboard.writeText(text);
ToastUtils.success(this.$t("translator.copied_to_clipboard"));
},
},
};
</script>
<style scoped>
.glass-label {
@apply block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1;
}
</style>

View file

@ -3,7 +3,7 @@
class="flex flex-col flex-1 overflow-hidden min-w-0 bg-gradient-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900"
>
<div class="flex-1 overflow-y-auto w-full">
<div class="space-y-4 p-4 md:p-6 max-w-5xl mx-auto w-full">
<div class="space-y-4 p-4 md:p-6 lg:p-8 w-full">
<div class="glass-card space-y-3">
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
{{ $t("tools.utilities") }}
@ -16,184 +16,61 @@
</div>
</div>
<div class="grid gap-4 md:grid-cols-2">
<RouterLink :to="{ name: 'ping' }" class="tool-card glass-card">
<div class="tool-card__icon bg-blue-50 text-blue-500 dark:bg-blue-900/30 dark:text-blue-200">
<MaterialDesignIcon icon-name="radar" class="w-6 h-6" />
</div>
<div class="flex-1">
<div class="tool-card__title">{{ $t("tools.ping.title") }}</div>
<div class="tool-card__description">
{{ $t("tools.ping.description") }}
</div>
</div>
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
</RouterLink>
<div class="glass-card">
<div class="relative">
<MaterialDesignIcon
icon-name="magnify"
class="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400"
/>
<input
v-model="searchQuery"
type="text"
:placeholder="$t('common.search')"
class="w-full pl-10 pr-4 py-3 bg-white/50 dark:bg-zinc-900/50 border border-gray-200 dark:border-zinc-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500"
/>
</div>
</div>
<RouterLink :to="{ name: 'rnprobe' }" class="tool-card glass-card">
<div
class="tool-card__icon bg-purple-50 text-purple-500 dark:bg-purple-900/30 dark:text-purple-200"
>
<MaterialDesignIcon icon-name="radar" class="w-6 h-6" />
<div class="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<RouterLink
v-for="tool in filteredTools"
:key="tool.name"
:to="tool.route"
:class="['tool-card', 'glass-card', tool.customClass].filter(Boolean)"
>
<div :class="tool.iconBg">
<MaterialDesignIcon v-if="tool.icon" :icon-name="tool.icon" class="w-6 h-6" />
<img
v-else-if="tool.image"
:src="tool.image"
:class="tool.imageClass"
:alt="tool.imageAlt"
/>
</div>
<div class="flex-1">
<div class="tool-card__title">{{ $t("tools.rnprobe.title") }}</div>
<div class="tool-card__title">{{ tool.title }}</div>
<div class="tool-card__description">
{{ $t("tools.rnprobe.description") }}
{{ tool.description }}
</div>
</div>
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
</RouterLink>
<RouterLink :to="{ name: 'rncp' }" class="tool-card glass-card">
<div
class="tool-card__icon bg-green-50 text-green-500 dark:bg-green-900/30 dark:text-green-200"
>
<MaterialDesignIcon icon-name="swap-horizontal" class="w-6 h-6" />
</div>
<div class="flex-1">
<div class="tool-card__title">{{ $t("tools.rncp.title") }}</div>
<div class="tool-card__description">
{{ $t("tools.rncp.description") }}
</div>
</div>
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
</RouterLink>
<RouterLink :to="{ name: 'rnstatus' }" class="tool-card glass-card">
<div
class="tool-card__icon bg-orange-50 text-orange-500 dark:bg-orange-900/30 dark:text-orange-200"
>
<MaterialDesignIcon icon-name="chart-line" class="w-6 h-6" />
</div>
<div class="flex-1">
<div class="tool-card__title">{{ $t("tools.rnstatus.title") }}</div>
<div class="tool-card__description">
{{ $t("tools.rnstatus.description") }}
</div>
</div>
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
</RouterLink>
<RouterLink :to="{ name: 'rnpath' }" class="tool-card glass-card">
<div
class="tool-card__icon bg-indigo-50 text-indigo-500 dark:bg-indigo-900/30 dark:text-indigo-200"
>
<MaterialDesignIcon icon-name="route" class="w-6 h-6" />
</div>
<div class="flex-1">
<div class="tool-card__title">{{ $t("tools.rnpath.title") }}</div>
<div class="tool-card__description">
{{ $t("tools.rnpath.description") }}
</div>
</div>
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
</RouterLink>
<RouterLink :to="{ name: 'translator' }" class="tool-card glass-card">
<div
class="tool-card__icon bg-indigo-50 text-indigo-500 dark:bg-indigo-900/30 dark:text-indigo-200"
>
<MaterialDesignIcon icon-name="translate" class="w-6 h-6" />
</div>
<div class="flex-1">
<div class="tool-card__title">{{ $t("tools.translator.title") }}</div>
<div class="tool-card__description">
{{ $t("tools.translator.description") }}
</div>
</div>
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
</RouterLink>
<RouterLink :to="{ name: 'forwarder' }" class="tool-card glass-card">
<div class="tool-card__icon bg-rose-50 text-rose-500 dark:bg-rose-900/30 dark:text-rose-200">
<MaterialDesignIcon icon-name="email-send-outline" class="w-6 h-6" />
</div>
<div class="flex-1">
<div class="tool-card__title">{{ $t("tools.forwarder.title") }}</div>
<div class="tool-card__description">
{{ $t("tools.forwarder.description") }}
</div>
</div>
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
</RouterLink>
<RouterLink :to="{ name: 'documentation' }" class="tool-card glass-card">
<div class="tool-card__icon bg-cyan-50 text-cyan-500 dark:bg-cyan-900/30 dark:text-cyan-200">
<MaterialDesignIcon icon-name="book-open-variant" class="w-6 h-6" />
</div>
<div class="flex-1">
<div class="tool-card__title">{{ $t("docs.title") }}</div>
<div class="tool-card__description">
{{ $t("docs.subtitle") }}
</div>
</div>
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
</RouterLink>
<RouterLink :to="{ name: 'micron-editor' }" class="tool-card glass-card">
<div class="tool-card__icon bg-teal-50 text-teal-500 dark:bg-teal-900/30 dark:text-teal-200">
<MaterialDesignIcon icon-name="code-tags" class="w-6 h-6" />
</div>
<div class="flex-1">
<div class="tool-card__title">{{ $t("tools.micron_editor.title") }}</div>
<div class="tool-card__description">
{{ $t("tools.micron_editor.description") }}
</div>
</div>
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
</RouterLink>
<RouterLink :to="{ name: 'paper-message' }" class="tool-card glass-card">
<div class="tool-card__icon bg-blue-50 text-blue-500 dark:bg-blue-900/30 dark:text-blue-200">
<MaterialDesignIcon icon-name="qrcode" class="w-6 h-6" />
</div>
<div class="flex-1">
<div class="tool-card__title">{{ $t("tools.paper_message.title") }}</div>
<div class="tool-card__description">
{{ $t("tools.paper_message.description") }}
</div>
</div>
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
</RouterLink>
<RouterLink :to="{ name: 'rnode-flasher' }" class="tool-card glass-card">
<div
class="tool-card__icon bg-purple-50 text-purple-500 dark:bg-purple-900/30 dark:text-purple-200"
>
<img :src="rnodeLogoPath" class="w-8 h-8 rounded-full" alt="RNode" />
</div>
<div class="flex-1">
<div class="tool-card__title">{{ $t("tools.rnode_flasher.title") }}</div>
<div class="tool-card__description">
{{ $t("tools.rnode_flasher.description") }}
</div>
</div>
<div class="flex items-center gap-2">
<div v-if="tool.extraAction" class="flex items-center gap-2">
<a
href="/rnode-flasher/index.html"
target="_blank"
:href="tool.extraAction.href"
:target="tool.extraAction.target"
class="p-2 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-lg transition-colors text-gray-400 hover:text-blue-500"
@click.stop
>
<MaterialDesignIcon icon-name="open-in-new" class="size-5" />
<MaterialDesignIcon :icon-name="tool.extraAction.icon" class="size-5" />
</a>
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
</div>
<MaterialDesignIcon v-else icon-name="chevron-right" class="tool-card__chevron" />
</RouterLink>
</div>
<RouterLink :to="{ name: 'debug-logs' }" class="tool-card glass-card border-dashed border-2">
<div class="tool-card__icon bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400">
<MaterialDesignIcon icon-name="console" class="w-6 h-6" />
</div>
<div class="flex-1">
<div class="tool-card__title">Debug Logs</div>
<div class="tool-card__description">
View and export internal system logs for troubleshooting.
</div>
</div>
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
</RouterLink>
<div v-if="filteredTools.length === 0" class="glass-card text-center py-12">
<MaterialDesignIcon icon-name="magnify" class="w-12 h-12 mx-auto mb-4 text-gray-400" />
<div class="text-gray-600 dark:text-gray-400">{{ $t("common.no_results") }}</div>
</div>
</div>
</div>
@ -210,8 +87,148 @@ export default {
data() {
return {
rnodeLogoPath: "/rnode-flasher/reticulum_logo_512.png",
searchQuery: "",
tools: [
{
name: "ping",
route: { name: "ping" },
icon: "radar",
iconBg: "tool-card__icon bg-blue-50 text-blue-500 dark:bg-blue-900/30 dark:text-blue-200",
titleKey: "tools.ping.title",
descriptionKey: "tools.ping.description",
},
{
name: "rnprobe",
route: { name: "rnprobe" },
icon: "radar",
iconBg: "tool-card__icon bg-purple-50 text-purple-500 dark:bg-purple-900/30 dark:text-purple-200",
titleKey: "tools.rnprobe.title",
descriptionKey: "tools.rnprobe.description",
},
{
name: "rncp",
route: { name: "rncp" },
icon: "swap-horizontal",
iconBg: "tool-card__icon bg-green-50 text-green-500 dark:bg-green-900/30 dark:text-green-200",
titleKey: "tools.rncp.title",
descriptionKey: "tools.rncp.description",
},
{
name: "rnstatus",
route: { name: "rnstatus" },
icon: "chart-line",
iconBg: "tool-card__icon bg-orange-50 text-orange-500 dark:bg-orange-900/30 dark:text-orange-200",
titleKey: "tools.rnstatus.title",
descriptionKey: "tools.rnstatus.description",
},
{
name: "rnpath",
route: { name: "rnpath" },
icon: "route",
iconBg: "tool-card__icon bg-indigo-50 text-indigo-500 dark:bg-indigo-900/30 dark:text-indigo-200",
titleKey: "tools.rnpath.title",
descriptionKey: "tools.rnpath.description",
},
{
name: "translator",
route: { name: "translator" },
icon: "translate",
iconBg: "tool-card__icon bg-indigo-50 text-indigo-500 dark:bg-indigo-900/30 dark:text-indigo-200",
titleKey: "tools.translator.title",
descriptionKey: "tools.translator.description",
},
{
name: "bots",
route: { name: "bots" },
icon: "robot",
iconBg: "tool-card__icon bg-blue-50 text-blue-500 dark:bg-blue-900/30 dark:text-blue-200",
titleKey: "tools.bots.title",
descriptionKey: "tools.bots.description",
},
{
name: "forwarder",
route: { name: "forwarder" },
icon: "email-send-outline",
iconBg: "tool-card__icon bg-rose-50 text-rose-500 dark:bg-rose-900/30 dark:text-rose-200",
titleKey: "tools.forwarder.title",
descriptionKey: "tools.forwarder.description",
},
{
name: "documentation",
route: { name: "documentation" },
icon: "book-open-variant",
iconBg: "tool-card__icon bg-cyan-50 text-cyan-500 dark:bg-cyan-900/30 dark:text-cyan-200",
titleKey: "docs.title",
descriptionKey: "docs.subtitle",
},
{
name: "micron-editor",
route: { name: "micron-editor" },
icon: "code-tags",
iconBg: "tool-card__icon bg-teal-50 text-teal-500 dark:bg-teal-900/30 dark:text-teal-200",
titleKey: "tools.micron_editor.title",
descriptionKey: "tools.micron_editor.description",
},
{
name: "paper-message",
route: { name: "paper-message" },
icon: "qrcode",
iconBg: "tool-card__icon bg-blue-50 text-blue-500 dark:bg-blue-900/30 dark:text-blue-200",
titleKey: "tools.paper_message.title",
descriptionKey: "tools.paper_message.description",
},
{
name: "rnode-flasher",
route: { name: "rnode-flasher" },
icon: null,
image: "/rnode-flasher/reticulum_logo_512.png",
imageClass: "w-8 h-8 rounded-full",
imageAlt: "RNode",
iconBg: "tool-card__icon bg-purple-50 text-purple-500 dark:bg-purple-900/30 dark:text-purple-200",
titleKey: "tools.rnode_flasher.title",
descriptionKey: "tools.rnode_flasher.description",
extraAction: {
href: "/rnode-flasher/index.html",
target: "_blank",
icon: "open-in-new",
},
},
{
name: "debug-logs",
route: { name: "debug-logs" },
icon: "console",
iconBg: "tool-card__icon bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400",
titleKey: null,
title: "Debug Logs",
descriptionKey: null,
description: "View and export internal system logs for troubleshooting.",
customClass: "border-dashed border-2",
},
],
};
},
computed: {
filteredTools() {
const toolsWithTranslations = this.tools.map((tool) => ({
...tool,
title: tool.title || (tool.titleKey ? this.$t(tool.titleKey) : ""),
description: tool.description || (tool.descriptionKey ? this.$t(tool.descriptionKey) : ""),
}));
if (!this.searchQuery.trim()) {
return toolsWithTranslations;
}
const query = this.searchQuery.toLowerCase().trim();
return toolsWithTranslations.filter((tool) => {
return (
tool.title.toLowerCase().includes(query) ||
tool.description.toLowerCase().includes(query) ||
tool.name.toLowerCase().includes(query)
);
});
},
},
};
</script>

View file

@ -1,4 +1,31 @@
{
"bots": {
"title": "LXMFy-Bots",
"description": "Verwalten Sie automatisierte Bots für Echo, Notizen und Erinnerungen mit LXMFy.",
"bot_framework": "Bot-Framework",
"lxmfy_not_detected": "LXMFy nicht erkannt",
"lxmfy_not_detected_desc": "Um Bots zu verwenden, müssen Sie das LXMFy-Paket installieren:",
"install_via_pip": "Über pip installieren",
"create_new_bot": "Neuen Bot erstellen",
"running_bots": "Laufende Bots",
"no_bots_running": "Derzeit laufen keine Bots.",
"select": "Auswählen",
"start_bot": "Bot starten",
"stop_bot": "Bot stoppen",
"restart_bot": "Bot neu starten",
"saved_bots": "Gespeicherte Bots",
"bot_name": "Bot-Name",
"cancel": "Abbrechen",
"bot_started": "Bot erfolgreich gestartet",
"bot_stopped": "Bot gestoppt",
"failed_to_start": "Bot konnte nicht gestartet werden",
"failed_to_stop": "Bot konnte nicht gestoppt werden",
"delete_bot": "Bot löschen",
"export_identity": "Identität exportieren",
"bot_deleted": "Bot erfolgreich gelöscht",
"failed_to_delete": "Bot konnte nicht gelöscht werden",
"more_bots_coming": "Weitere Bots folgen in Kürze!"
},
"app": {
"name": "Reticulum MeshChatX",
"sync_messages": "Nachrichten synchronisieren",
@ -183,7 +210,9 @@
"shutdown": "Ausschalten",
"acknowledge_reset": "Bestätigen & Zurücksetzen",
"confirm": "Bestätigen",
"delete_confirm": "Sind Sie sicher, dass Sie dies löschen möchten? Dies kann nicht rückgängig gemacht werden."
"delete_confirm": "Sind Sie sicher, dass Sie dies löschen möchten? Dies kann nicht rückgängig gemacht werden.",
"search": "Werkzeuge suchen...",
"no_results": "Keine Werkzeuge gefunden"
},
"identities": {
"title": "Identitäten",
@ -665,6 +694,10 @@
"paper_message": {
"title": "Papiernachricht",
"description": "Erstellen und lesen Sie LXMF-signierte Papiernachrichten über QR-Codes."
},
"bots": {
"title": "LXMFy-Bots",
"description": "Verwalten Sie automatisierte Bots für Echo, Notizen und Erinnerungen mit LXMFy."
}
},
"ping": {

View file

@ -1,4 +1,31 @@
{
"bots": {
"title": "LXMFy Bots",
"description": "Manage automated bots for echo, notes, and reminders using LXMFy.",
"bot_framework": "Bot Framework",
"lxmfy_not_detected": "LXMFy not detected",
"lxmfy_not_detected_desc": "To use bots, you must install the LXMFy package:",
"install_via_pip": "Install via pip",
"create_new_bot": "Create New Bot",
"running_bots": "Running Bots",
"no_bots_running": "No bots are currently running.",
"select": "Select",
"start_bot": "Start Bot",
"stop_bot": "Stop Bot",
"restart_bot": "Restart Bot",
"saved_bots": "Saved Bots",
"bot_name": "Bot Name",
"cancel": "Cancel",
"bot_started": "Bot started successfully",
"bot_stopped": "Bot stopped",
"failed_to_start": "Failed to start bot",
"failed_to_stop": "Failed to stop bot",
"delete_bot": "Delete Bot",
"export_identity": "Export Identity",
"bot_deleted": "Bot deleted successfully",
"failed_to_delete": "Failed to delete bot",
"more_bots_coming": "More bots coming soon!"
},
"app": {
"name": "Reticulum MeshChatX",
"sync_messages": "Sync Messages",
@ -183,7 +210,9 @@
"shutdown": "Shutdown",
"acknowledge_reset": "Acknowledge & Reset",
"confirm": "Confirm",
"delete_confirm": "Are you sure you want to delete this? This cannot be undone."
"delete_confirm": "Are you sure you want to delete this? This cannot be undone.",
"search": "Search tools...",
"no_results": "No tools found"
},
"identities": {
"title": "Identities",
@ -665,6 +694,10 @@
"paper_message": {
"title": "Paper Message",
"description": "Generate and read LXMF signed paper messages via QR codes."
},
"bots": {
"title": "LXMFy Bots",
"description": "Manage automated bots for echo, notes, and reminders using LXMFy."
}
},
"ping": {

View file

@ -1,4 +1,31 @@
{
"bots": {
"title": "Боты LXMFy",
"description": "Управление автоматическими ботами для эха, заметок и напоминаний с помощью LXMFy.",
"bot_framework": "Фреймворк ботов",
"lxmfy_not_detected": "LXMFy не обнаружен",
"lxmfy_not_detected_desc": "Для использования ботов необходимо установить пакет LXMFy:",
"install_via_pip": "Установить через pip",
"create_new_bot": "Создать нового бота",
"running_bots": "Запущенные боты",
"no_bots_running": "В данный момент нет запущенных ботов.",
"select": "Выбрать",
"start_bot": "Запустить бота",
"stop_bot": "Остановить бота",
"restart_bot": "Перезапустить бота",
"saved_bots": "Сохраненные боты",
"bot_name": "Имя бота",
"cancel": "Отмена",
"bot_started": "Бот успешно запущен",
"bot_stopped": "Бот остановлен",
"failed_to_start": "Не удалось запустить бота",
"failed_to_stop": "Не удалось остановить бота",
"delete_bot": "Удалить бота",
"export_identity": "Экспорт личности",
"bot_deleted": "Бот успешно удален",
"failed_to_delete": "Не удалось удалить бота",
"more_bots_coming": "Скоро появятся новые боты!"
},
"app": {
"name": "Reticulum MeshChatX",
"sync_messages": "Синхронизировать сообщения",
@ -183,7 +210,9 @@
"shutdown": "Выключить",
"acknowledge_reset": "Подтвердить и сбросить",
"confirm": "Подтвердить",
"delete_confirm": "Вы уверены, что хотите удалить это? Это действие нельзя отменить."
"delete_confirm": "Вы уверены, что хотите удалить это? Это действие нельзя отменить.",
"search": "Поиск инструментов...",
"no_results": "Инструменты не найдены"
},
"identities": {
"title": "Личности",
@ -665,6 +694,10 @@
"paper_message": {
"title": "Бумажное сообщение",
"description": "Создание и чтение подписанных бумажных сообщений LXMF через QR-коды."
},
"bots": {
"title": "LXMFy Боты",
"description": "Управление автоматизированными ботами для эха, заметок и напоминаний с помощью LXMFy."
}
},
"ping": {

View file

@ -189,6 +189,11 @@ const router = createRouter({
path: "/translator",
component: defineAsyncComponent(() => import("./components/translator/TranslatorPage.vue")),
},
{
name: "bots",
path: "/bots",
component: defineAsyncComponent(() => import("./components/tools/BotsPage.vue")),
},
{
name: "forwarder",
path: "/forwarder",

View file

@ -20,7 +20,7 @@ def main():
sys.exit(1)
try:
with open(en_path, "r", encoding="utf-8") as f:
with open(en_path, encoding="utf-8") as f:
en_data = json.load(f)
template = clear_values(en_data)
@ -29,7 +29,7 @@ def main():
json.dump(template, f, indent=4, ensure_ascii=False)
print(
f"Successfully generated {out_path} with all keys from {en_path} (empty values)."
f"Successfully generated {out_path} with all keys from {en_path} (empty values).",
)
except Exception as e:
print(f"Error generating locale template: {e}")

View file

@ -1,9 +1,10 @@
import os
import random
import secrets
import shutil
import tempfile
import time
import random
import secrets
from meshchatx.src.backend.database import Database
@ -68,7 +69,7 @@ def test_db_performance():
convs = db.messages.get_conversations()
end_time = time.time()
print(
f"get_conversations() returned {len(convs)} conversations in {end_time - start_time:.4f} seconds"
f"get_conversations() returned {len(convs)} conversations in {end_time - start_time:.4f} seconds",
)
# Test get_conversation_messages for a random peer
@ -78,7 +79,7 @@ def test_db_performance():
msgs = db.messages.get_conversation_messages(target_peer, limit=50)
end_time = time.time()
print(
f"get_conversation_messages() returned {len(msgs)} messages in {end_time - start_time:.4f} seconds"
f"get_conversation_messages() returned {len(msgs)} messages in {end_time - start_time:.4f} seconds",
)
# Test unread states for all peers
@ -87,7 +88,7 @@ def test_db_performance():
_ = db.messages.get_conversations_unread_states(peer_hashes)
end_time = time.time()
print(
f"get_conversations_unread_states() for {len(peer_hashes)} peers took {end_time - start_time:.4f} seconds"
f"get_conversations_unread_states() for {len(peer_hashes)} peers took {end_time - start_time:.4f} seconds",
)
# Test announces performance

View file

@ -1,9 +1,10 @@
import os
import psutil
import gc
import os
import time
from functools import wraps
import psutil
def get_memory_usage_mb():
"""Returns the current process memory usage in MB."""
@ -81,5 +82,5 @@ class MemoryTracker:
self.duration_ms = (self.end_time - self.start_time) * 1000
self.mem_delta = self.end_mem - self.start_mem
print(
f"TRACKER [{self.name}]: {self.duration_ms:.2f}ms, {self.mem_delta:.2f}MB"
f"TRACKER [{self.name}]: {self.duration_ms:.2f}ms, {self.mem_delta:.2f}MB",
)

View file

@ -1,6 +1,7 @@
import pytest
from unittest.mock import patch
import asyncio
from unittest.mock import patch
import pytest
@pytest.fixture(autouse=True)

View file

@ -1,13 +1,15 @@
import gc
import json
import os
import random
import secrets
import shutil
import tempfile
import time
import random
import secrets
import psutil
import gc
import json
from unittest.mock import MagicMock
import psutil
from meshchatx.src.backend.database import Database
@ -72,7 +74,9 @@ class MapBenchmarker:
)
self.record_benchmark(
f"Telemetry Insertion ({count} entries)", run_telemetry, count
f"Telemetry Insertion ({count} entries)",
run_telemetry,
count,
)
def benchmark_telemetry_retrieval(self, count=100):
@ -90,7 +94,9 @@ class MapBenchmarker:
self.db.telemetry.get_telemetry_history(dest_hash, limit=100)
self.record_benchmark(
f"Telemetry History Retrieval ({count} calls)", run_retrieval, count
f"Telemetry History Retrieval ({count} calls)",
run_retrieval,
count,
)
def benchmark_drawing_storage(self, count=500):
@ -112,7 +118,7 @@ class MapBenchmarker:
}
for i in range(100)
],
}
},
)
def run_drawings():
@ -125,7 +131,9 @@ class MapBenchmarker:
)
self.record_benchmark(
f"Map Drawing Insertion ({count} layers)", run_drawings, count
f"Map Drawing Insertion ({count} layers)",
run_drawings,
count,
)
def benchmark_drawing_listing(self, count=100):
@ -154,7 +162,9 @@ class MapBenchmarker:
mm.list_mbtiles()
self.record_benchmark(
f"MBTiles Listing ({count} calls, 5 files)", run_list, count
f"MBTiles Listing ({count} calls, 5 files)",
run_list,
count,
)
@ -173,7 +183,7 @@ def main():
print("-" * 80)
for r in bench.results:
print(
f"{r['name']:40} | {r['duration_ms']:8.2f} ms | {r['memory_growth_mb']:8.2f} MB"
f"{r['name']:40} | {r['duration_ms']:8.2f} ms | {r['memory_growth_mb']:8.2f} MB",
)
print("=" * 80)

View file

@ -1,11 +1,13 @@
import gc
import os
import random
import secrets
import shutil
import tempfile
import time
import random
import secrets
import psutil
import gc
from meshchatx.src.backend.database import Database
from meshchatx.src.backend.recovery import CrashRecovery
@ -112,7 +114,9 @@ class PerformanceBenchmarker:
recovery.run_diagnosis(file=open(os.devnull, "w"))
self.record_benchmark(
"CrashRecovery Diagnosis Overhead (50 runs)", run_recovery_check, 50
"CrashRecovery Diagnosis Overhead (50 runs)",
run_recovery_check,
50,
)
def benchmark_identity_generation(self, count=20):
@ -123,7 +127,9 @@ class PerformanceBenchmarker:
RNS.Identity(create_keys=True)
self.record_benchmark(
f"RNS Identity Generation ({count} identities)", run_gen, count
f"RNS Identity Generation ({count} identities)",
run_gen,
count,
)
def benchmark_identity_listing(self, count=100):
@ -142,7 +148,9 @@ class PerformanceBenchmarker:
manager.list_identities(current_identity_hash=hashes[0])
self.record_benchmark(
f"Identity Listing ({count} runs, 10 identities)", run_list, count
f"Identity Listing ({count} runs, 10 identities)",
run_list,
count,
)
@ -161,7 +169,7 @@ def main():
print("-" * 80)
for r in bench.results:
print(
f"{r['name']:40} | {r['duration_ms']:8.2f} ms | {r['memory_growth_mb']:8.2f} MB"
f"{r['name']:40} | {r['duration_ms']:8.2f} ms | {r['memory_growth_mb']:8.2f} MB",
)
print("=" * 80)

View file

@ -1,18 +1,19 @@
import os
import sys
import time
import shutil
import tempfile
import random
import secrets
import shutil
import sys
import tempfile
import time
# Ensure we can import meshchatx
sys.path.append(os.getcwd())
import json
from meshchatx.src.backend.database import Database
from meshchatx.src.backend.identity_manager import IdentityManager
from meshchatx.src.backend.database.telephone import TelephoneDAO
from meshchatx.src.backend.identity_manager import IdentityManager
from tests.backend.benchmarking_utils import (
benchmark,
get_memory_usage_mb,
@ -76,7 +77,7 @@ class BackendBenchmarker:
"delivery_attempts": 1,
"title": f"Extreme Msg {b + i}",
"content": secrets.token_bytes(
1024
1024,
).hex(), # 2KB hex string
"fields": json.dumps({"test": "data" * 10}),
"timestamp": time.time() - (total_messages - (b + i)),
@ -87,13 +88,15 @@ class BackendBenchmarker:
}
self.db.messages.upsert_lxmf_message(msg)
print(
f" Progress: {b + batch_size}/{total_messages} messages inserted..."
f" Progress: {b + batch_size}/{total_messages} messages inserted...",
)
@benchmark("EXTREME: Search 100k Messages (Wildcard)", iterations=5)
def run_extreme_search():
return self.db.messages.get_conversation_messages(
peer_hashes[0], limit=100, offset=50000
peer_hashes[0],
limit=100,
offset=50000,
)
_, res_flood = run_extreme_flood()
@ -115,7 +118,7 @@ class BackendBenchmarker:
data = {
"destination_hash": secrets.token_hex(16),
"aspect": random.choice(
["lxmf.delivery", "lxst.telephony", "group.chat"]
["lxmf.delivery", "lxst.telephony", "group.chat"],
),
"identity_hash": secrets.token_hex(16),
"identity_public_key": secrets.token_hex(32),
@ -130,7 +133,9 @@ class BackendBenchmarker:
@benchmark("EXTREME: Filter 50k Announces (Complex)", iterations=10)
def run_ann_filter():
return self.db.announces.get_filtered_announces(
aspect="lxmf.delivery", limit=100, offset=25000
aspect="lxmf.delivery",
limit=100,
offset=25000,
)
_, res_flood = run_ann_flood()
@ -164,7 +169,8 @@ class BackendBenchmarker:
@benchmark("Database Initialization", iterations=5)
def run():
tmp_db_path = os.path.join(
self.temp_dir, f"init_test_{random.randint(0, 1000)}.db"
self.temp_dir,
f"init_test_{random.randint(0, 1000)}.db",
)
db = Database(tmp_db_path)
db.initialize()
@ -210,7 +216,9 @@ class BackendBenchmarker:
@benchmark("Get Messages for Conversation (offset 500)", iterations=20)
def get_messages():
return self.db.messages.get_conversation_messages(
peer_hashes[0], limit=50, offset=500
peer_hashes[0],
limit=50,
offset=500,
)
_, res = upsert_batch()
@ -295,7 +303,7 @@ class BackendBenchmarker:
print(f"{'-' * 40}-|-{'-' * 10}-|-{'-' * 10}")
for r in self.results:
print(
f"{r.name:40} | {r.duration_ms:8.2f} ms | {r.memory_delta_mb:8.2f} MB"
f"{r.name:40} | {r.duration_ms:8.2f} ms | {r.memory_delta_mb:8.2f} MB",
)
print(f"{'=' * 59}")
print(f"Final Memory Usage: {get_memory_usage_mb():.2f} MB")
@ -306,7 +314,9 @@ if __name__ == "__main__":
parser = argparse.ArgumentParser(description="MeshChatX Backend Benchmarker")
parser.add_argument(
"--extreme", action="store_true", help="Run extreme stress tests"
"--extreme",
action="store_true",
help="Run extreme stress tests",
)
args = parser.parse_args()

View file

@ -1,9 +1,11 @@
import os
import tempfile
import pytest
from meshchatx.src.backend.database.announces import AnnounceDAO
from meshchatx.src.backend.database.provider import DatabaseProvider
from meshchatx.src.backend.database.schema import DatabaseSchema
from meshchatx.src.backend.database.announces import AnnounceDAO
@pytest.fixture
@ -37,7 +39,7 @@ def test_get_filtered_announces_identity_hash(announce_dao):
"rssi": -50,
"snr": 10,
"quality": 1.0,
}
},
)
announce_dao.upsert_announce(
{
@ -49,7 +51,7 @@ def test_get_filtered_announces_identity_hash(announce_dao):
"rssi": -50,
"snr": 10,
"quality": 1.0,
}
},
)
announce_dao.upsert_announce(
{
@ -61,7 +63,7 @@ def test_get_filtered_announces_identity_hash(announce_dao):
"rssi": -50,
"snr": 10,
"quality": 1.0,
}
},
)
# Test filtering by identity_hash
@ -71,7 +73,8 @@ def test_get_filtered_announces_identity_hash(announce_dao):
# Test filtering by identity_hash and aspect
results = announce_dao.get_filtered_announces(
identity_hash="ident1", aspect="lxmf.propagation"
identity_hash="ident1",
aspect="lxmf.propagation",
)
assert len(results) == 1
assert results[0]["destination_hash"] == "dest1"
@ -89,6 +92,7 @@ def test_get_filtered_announces_robustness(announce_dao):
# Test with multiple filters that yield no results
results = announce_dao.get_filtered_announces(
identity_hash="ident1", aspect="non_existent_aspect"
identity_hash="ident1",
aspect="non_existent_aspect",
)
assert len(results) == 0

View file

@ -1,11 +1,13 @@
import asyncio
import json
import shutil
import tempfile
import pytest
import json
from unittest.mock import MagicMock, patch
from meshchatx.meshchat import ReticulumMeshChat
import pytest
import RNS
import asyncio
from meshchatx.meshchat import ReticulumMeshChat
@pytest.fixture

View file

@ -1,10 +1,12 @@
import shutil
import tempfile
import pytest
from unittest.mock import MagicMock, patch
from meshchatx.meshchat import ReticulumMeshChat
import pytest
import RNS
from meshchatx.meshchat import ReticulumMeshChat
@pytest.fixture
def temp_dir():
@ -36,43 +38,43 @@ async def test_app_status_endpoints(mock_rns_minimal, temp_dir):
with ExitStack() as stack:
# Patch all dependencies
stack.enter_context(
patch("meshchatx.src.backend.identity_context.MessageHandler")
patch("meshchatx.src.backend.identity_context.MessageHandler"),
)
stack.enter_context(
patch("meshchatx.src.backend.identity_context.AnnounceManager")
patch("meshchatx.src.backend.identity_context.AnnounceManager"),
)
stack.enter_context(
patch("meshchatx.src.backend.identity_context.ArchiverManager")
patch("meshchatx.src.backend.identity_context.ArchiverManager"),
)
stack.enter_context(patch("meshchatx.src.backend.identity_context.MapManager"))
stack.enter_context(patch("meshchatx.src.backend.identity_context.DocsManager"))
stack.enter_context(
patch("meshchatx.src.backend.identity_context.NomadNetworkManager")
patch("meshchatx.src.backend.identity_context.NomadNetworkManager"),
)
stack.enter_context(
patch("meshchatx.src.backend.identity_context.TelephoneManager")
patch("meshchatx.src.backend.identity_context.TelephoneManager"),
)
stack.enter_context(
patch("meshchatx.src.backend.identity_context.VoicemailManager")
patch("meshchatx.src.backend.identity_context.VoicemailManager"),
)
stack.enter_context(
patch("meshchatx.src.backend.identity_context.RingtoneManager")
patch("meshchatx.src.backend.identity_context.RingtoneManager"),
)
stack.enter_context(patch("meshchatx.src.backend.identity_context.RNCPHandler"))
stack.enter_context(
patch("meshchatx.src.backend.identity_context.RNStatusHandler")
patch("meshchatx.src.backend.identity_context.RNStatusHandler"),
)
stack.enter_context(
patch("meshchatx.src.backend.identity_context.RNProbeHandler")
patch("meshchatx.src.backend.identity_context.RNProbeHandler"),
)
stack.enter_context(
patch("meshchatx.src.backend.identity_context.TranslatorHandler")
patch("meshchatx.src.backend.identity_context.TranslatorHandler"),
)
stack.enter_context(
patch("meshchatx.src.backend.identity_context.CommunityInterfacesManager")
patch("meshchatx.src.backend.identity_context.CommunityInterfacesManager"),
)
stack.enter_context(
patch("meshchatx.src.backend.sideband_commands.SidebandCommands")
patch("meshchatx.src.backend.sideband_commands.SidebandCommands"),
)
stack.enter_context(patch("meshchatx.meshchat.Telemeter"))
stack.enter_context(patch("meshchatx.meshchat.CrashRecovery"))

View file

@ -1,9 +1,9 @@
import unittest
import hashlib
import json
import os
import shutil
import tempfile
import json
import hashlib
import unittest
from pathlib import Path
@ -55,7 +55,7 @@ class TestBackendIntegrity(unittest.TestCase):
def test_manifest_generation(self):
"""Test that the build script logic produces a valid manifest."""
manifest_path = self.generate_manifest()
with open(manifest_path, "r") as f:
with open(manifest_path) as f:
manifest = json.load(f)
self.assertEqual(len(manifest["files"]), 2)
@ -66,7 +66,7 @@ class TestBackendIntegrity(unittest.TestCase):
def test_tampering_detection_logic(self):
"""Test that modifying a file changes its hash (logic check)."""
manifest_path = self.generate_manifest()
with open(manifest_path, "r") as f:
with open(manifest_path) as f:
manifest = json.load(f)
old_hash = manifest["files"]["ReticulumMeshChatX"]

View file

@ -1,11 +1,13 @@
import json
import shutil
import tempfile
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import json
from unittest.mock import MagicMock, patch, AsyncMock
from meshchatx.meshchat import ReticulumMeshChat
import RNS
from meshchatx.meshchat import ReticulumMeshChat
@pytest.fixture
def temp_dir():
@ -74,7 +76,7 @@ async def test_banish_identity_with_blackhole(mock_rns_minimal, temp_dir):
# Verify DB call
app_instance.database.misc.add_blocked_destination.assert_called_with(
target_hash
target_hash,
)
# Verify RNS blackhole call
@ -100,7 +102,7 @@ async def test_banish_identity_with_resolution(mock_rns_minimal, temp_dir):
# Mock identity resolution
app_instance.database.announces.get_announce_by_hash.return_value = {
"identity_hash": ident_hash
"identity_hash": ident_hash,
}
request = MagicMock()
@ -147,7 +149,7 @@ async def test_banish_identity_disabled_integration(mock_rns_minimal, temp_dir):
# DB call should still happen
app_instance.database.misc.add_blocked_destination.assert_called_with(
target_hash
target_hash,
)
# RNS blackhole call should NOT happen
@ -189,7 +191,7 @@ async def test_lift_banishment(mock_rns_minimal, temp_dir):
# Verify DB call
app_instance.database.misc.delete_blocked_destination.assert_called_with(
target_hash
target_hash,
)
# Verify RNS unblackhole call
@ -213,7 +215,7 @@ async def test_get_blackhole_list(mock_rns_minimal, temp_dir):
"source": b"\x02" * 32,
"until": 1234567890,
"reason": "Spam",
}
},
}
request = MagicMock()

View file

@ -1,5 +1,7 @@
import pytest
from unittest.mock import MagicMock, patch
import pytest
from meshchatx.src.backend.community_interfaces import CommunityInterfacesManager
from meshchatx.src.backend.rnstatus_handler import RNStatusHandler
@ -42,7 +44,7 @@ async def test_rnstatus_integration_simulated():
"rxb": 0,
"txb": 0,
},
]
],
}
handler = RNStatusHandler(mock_reticulum)

View file

@ -1,8 +1,10 @@
import os
import pytest
from meshchatx.src.backend.database.contacts import ContactsDAO
from meshchatx.src.backend.database.provider import DatabaseProvider
from meshchatx.src.backend.database.schema import DatabaseSchema
from meshchatx.src.backend.database.contacts import ContactsDAO
@pytest.fixture
@ -39,7 +41,8 @@ def test_contacts_with_custom_image(db_provider):
# Test updating contact image
contacts_dao.update_contact(
contact["id"], custom_image="data:image/png;base64,updateddata"
contact["id"],
custom_image="data:image/png;base64,updateddata",
)
contact = contacts_dao.get_contact(contact["id"])

View file

@ -1,10 +1,11 @@
import unittest
import io
import os
import shutil
import tempfile
import sys
import io
import sqlite3
import sys
import tempfile
import unittest
from meshchatx.src.backend.recovery.crash_recovery import CrashRecovery

View file

@ -1,7 +1,8 @@
import unittest
import os
import tempfile
import shutil
import tempfile
import unittest
from meshchatx.src.backend.database.provider import DatabaseProvider
from meshchatx.src.backend.database.schema import DatabaseSchema
@ -42,7 +43,8 @@ class TestDatabaseRobustness(unittest.TestCase):
)
""")
self.provider.execute(
"INSERT INTO config (key, value) VALUES (?, ?)", ("database_version", "1")
"INSERT INTO config (key, value) VALUES (?, ?)",
("database_version", "1"),
)
# 3. Attempt initialization.
@ -77,7 +79,7 @@ class TestDatabaseRobustness(unittest.TestCase):
# 3. Version should now be set to LATEST
row = self.provider.fetchone(
"SELECT value FROM config WHERE key = 'database_version'"
"SELECT value FROM config WHERE key = 'database_version'",
)
self.assertEqual(int(row["value"]), self.schema.LATEST_VERSION)

View file

@ -3,6 +3,7 @@ import shutil
import tempfile
import pytest
from meshchatx.src.backend.database import Database
@ -20,7 +21,8 @@ def test_database_snapshot_creation(temp_dir):
# Add some data
db.execute_sql(
"INSERT INTO config (key, value) VALUES (?, ?)", ("test_key", "test_value")
"INSERT INTO config (key, value) VALUES (?, ?)",
("test_key", "test_value"),
)
# Create snapshot

View file

@ -1,8 +1,10 @@
import time
import pytest
import logging
from meshchatx.src.backend.persistent_log_handler import PersistentLogHandler
import time
import pytest
from meshchatx.src.backend.database import Database
from meshchatx.src.backend.persistent_log_handler import PersistentLogHandler
@pytest.fixture

View file

@ -141,7 +141,7 @@ def create_mock_zip(zip_path, file_list):
)
@given(
root_folder_name=st.text(min_size=1, max_size=50).filter(
lambda x: "/" not in x and x not in [".", ".."]
lambda x: "/" not in x and x not in [".", ".."],
),
docs_file=st.text(min_size=1, max_size=50).filter(lambda x: "/" not in x),
)

View file

@ -5,6 +5,7 @@ from unittest.mock import MagicMock, patch
import pytest
import RNS
from meshchatx.meshchat import ReticulumMeshChat
@ -39,7 +40,9 @@ def mock_rns():
patch.object(MockIdentityClass, "from_file", return_value=mock_id_instance),
patch.object(MockIdentityClass, "recall", return_value=mock_id_instance),
patch.object(
MockIdentityClass, "from_bytes", return_value=mock_id_instance
MockIdentityClass,
"from_bytes",
return_value=mock_id_instance,
),
):
mock_transport.interfaces = []
@ -73,7 +76,7 @@ def test_emergency_mode_startup_logic(mock_rns, temp_dir):
patch("meshchatx.src.backend.identity_context.DocsManager"),
patch("meshchatx.src.backend.identity_context.NomadNetworkManager"),
patch(
"meshchatx.src.backend.identity_context.TelephoneManager"
"meshchatx.src.backend.identity_context.TelephoneManager",
) as mock_tel_class,
patch("meshchatx.src.backend.identity_context.VoicemailManager"),
patch("meshchatx.src.backend.identity_context.RingtoneManager"),
@ -83,10 +86,10 @@ def test_emergency_mode_startup_logic(mock_rns, temp_dir):
patch("meshchatx.src.backend.identity_context.TranslatorHandler"),
patch("meshchatx.src.backend.identity_context.CommunityInterfacesManager"),
patch(
"meshchatx.src.backend.identity_context.IntegrityManager"
"meshchatx.src.backend.identity_context.IntegrityManager",
) as mock_integrity_class,
patch(
"meshchatx.src.backend.identity_context.IdentityContext.start_background_threads"
"meshchatx.src.backend.identity_context.IdentityContext.start_background_threads",
),
):
# Initialize app in emergency mode
@ -139,7 +142,7 @@ def test_emergency_mode_env_var(mock_rns, temp_dir):
patch("meshchatx.src.backend.identity_context.TranslatorHandler"),
patch("meshchatx.src.backend.identity_context.CommunityInterfacesManager"),
patch(
"meshchatx.src.backend.identity_context.IdentityContext.start_background_threads"
"meshchatx.src.backend.identity_context.IdentityContext.start_background_threads",
),
):
# We need to simulate the argparse processing that happens in main()
@ -170,7 +173,7 @@ def test_normal_mode_startup_logic(mock_rns, temp_dir):
patch("meshchatx.src.backend.identity_context.DocsManager"),
patch("meshchatx.src.backend.identity_context.NomadNetworkManager"),
patch(
"meshchatx.src.backend.identity_context.TelephoneManager"
"meshchatx.src.backend.identity_context.TelephoneManager",
) as mock_tel_class,
patch("meshchatx.src.backend.identity_context.VoicemailManager"),
patch("meshchatx.src.backend.identity_context.RingtoneManager"),
@ -180,10 +183,10 @@ def test_normal_mode_startup_logic(mock_rns, temp_dir):
patch("meshchatx.src.backend.identity_context.TranslatorHandler"),
patch("meshchatx.src.backend.identity_context.CommunityInterfacesManager"),
patch(
"meshchatx.src.backend.identity_context.IntegrityManager"
"meshchatx.src.backend.identity_context.IntegrityManager",
) as mock_integrity_class,
patch(
"meshchatx.src.backend.identity_context.IdentityContext.start_background_threads"
"meshchatx.src.backend.identity_context.IdentityContext.start_background_threads",
),
):
# Configure mocks BEFORE instantiating app

View file

@ -12,6 +12,11 @@ from hypothesis import strategies as st
from meshchatx.meshchat import ReticulumMeshChat
from meshchatx.src.backend.interface_config_parser import InterfaceConfigParser
from meshchatx.src.backend.lxmf_message_fields import (
LxmfAudioField,
LxmfFileAttachment,
LxmfImageField,
)
from meshchatx.src.backend.meshchat_utils import (
parse_lxmf_display_name,
parse_nomadnetwork_node_display_name,
@ -20,11 +25,6 @@ from meshchatx.src.backend.nomadnet_utils import (
convert_nomadnet_field_data_to_map,
convert_nomadnet_string_data_to_map,
)
from meshchatx.src.backend.lxmf_message_fields import (
LxmfAudioField,
LxmfFileAttachment,
LxmfImageField,
)
from meshchatx.src.backend.telemetry_utils import Telemeter
@ -122,39 +122,39 @@ def mock_app(temp_dir):
# Mock database and other managers to avoid heavy initialization
stack.enter_context(patch("meshchatx.src.backend.identity_context.Database"))
stack.enter_context(
patch("meshchatx.src.backend.identity_context.ConfigManager")
patch("meshchatx.src.backend.identity_context.ConfigManager"),
)
stack.enter_context(
patch("meshchatx.src.backend.identity_context.MessageHandler")
patch("meshchatx.src.backend.identity_context.MessageHandler"),
)
stack.enter_context(
patch("meshchatx.src.backend.identity_context.AnnounceManager")
patch("meshchatx.src.backend.identity_context.AnnounceManager"),
)
stack.enter_context(
patch("meshchatx.src.backend.identity_context.ArchiverManager")
patch("meshchatx.src.backend.identity_context.ArchiverManager"),
)
stack.enter_context(patch("meshchatx.src.backend.identity_context.MapManager"))
stack.enter_context(
patch("meshchatx.src.backend.identity_context.TelephoneManager")
patch("meshchatx.src.backend.identity_context.TelephoneManager"),
)
stack.enter_context(
patch("meshchatx.src.backend.identity_context.VoicemailManager")
patch("meshchatx.src.backend.identity_context.VoicemailManager"),
)
stack.enter_context(
patch("meshchatx.src.backend.identity_context.RingtoneManager")
patch("meshchatx.src.backend.identity_context.RingtoneManager"),
)
stack.enter_context(patch("meshchatx.src.backend.identity_context.RNCPHandler"))
stack.enter_context(
patch("meshchatx.src.backend.identity_context.RNStatusHandler")
patch("meshchatx.src.backend.identity_context.RNStatusHandler"),
)
stack.enter_context(
patch("meshchatx.src.backend.identity_context.RNProbeHandler")
patch("meshchatx.src.backend.identity_context.RNProbeHandler"),
)
stack.enter_context(
patch("meshchatx.src.backend.identity_context.TranslatorHandler")
patch("meshchatx.src.backend.identity_context.TranslatorHandler"),
)
stack.enter_context(
patch("meshchatx.src.backend.identity_context.CommunityInterfacesManager")
patch("meshchatx.src.backend.identity_context.CommunityInterfacesManager"),
)
mock_async_utils = stack.enter_context(patch("meshchatx.meshchat.AsyncUtils"))
stack.enter_context(patch("LXMF.LXMRouter"))
@ -171,7 +171,9 @@ def mock_app(temp_dir):
stack.enter_context(patch("threading.Thread"))
stack.enter_context(
patch.object(
ReticulumMeshChat, "announce_loop", new=MagicMock(return_value=None)
ReticulumMeshChat,
"announce_loop",
new=MagicMock(return_value=None),
),
)
stack.enter_context(
@ -183,12 +185,16 @@ def mock_app(temp_dir):
)
stack.enter_context(
patch.object(
ReticulumMeshChat, "crawler_loop", new=MagicMock(return_value=None)
ReticulumMeshChat,
"crawler_loop",
new=MagicMock(return_value=None),
),
)
stack.enter_context(
patch.object(
ReticulumMeshChat, "auto_backup_loop", new=MagicMock(return_value=None)
ReticulumMeshChat,
"auto_backup_loop",
new=MagicMock(return_value=None),
),
)
@ -196,13 +202,13 @@ def mock_app(temp_dir):
mock_id.get_private_key = MagicMock(return_value=b"test_private_key")
stack.enter_context(
patch.object(MockIdentityClass, "from_file", return_value=mock_id)
patch.object(MockIdentityClass, "from_file", return_value=mock_id),
)
stack.enter_context(
patch.object(MockIdentityClass, "recall", return_value=mock_id)
patch.object(MockIdentityClass, "recall", return_value=mock_id),
)
stack.enter_context(
patch.object(MockIdentityClass, "from_bytes", return_value=mock_id)
patch.object(MockIdentityClass, "from_bytes", return_value=mock_id),
)
# Make run_async a no-op that doesn't trigger coroutine warnings

View file

@ -29,39 +29,39 @@ def mock_app(temp_dir):
with ExitStack() as stack:
stack.enter_context(patch("meshchatx.src.backend.identity_context.Database"))
stack.enter_context(
patch("meshchatx.src.backend.identity_context.ConfigManager")
patch("meshchatx.src.backend.identity_context.ConfigManager"),
)
stack.enter_context(
patch("meshchatx.src.backend.identity_context.MessageHandler")
patch("meshchatx.src.backend.identity_context.MessageHandler"),
)
stack.enter_context(
patch("meshchatx.src.backend.identity_context.AnnounceManager")
patch("meshchatx.src.backend.identity_context.AnnounceManager"),
)
stack.enter_context(
patch("meshchatx.src.backend.identity_context.ArchiverManager")
patch("meshchatx.src.backend.identity_context.ArchiverManager"),
)
stack.enter_context(patch("meshchatx.src.backend.identity_context.MapManager"))
stack.enter_context(
patch("meshchatx.src.backend.identity_context.TelephoneManager")
patch("meshchatx.src.backend.identity_context.TelephoneManager"),
)
stack.enter_context(
patch("meshchatx.src.backend.identity_context.VoicemailManager")
patch("meshchatx.src.backend.identity_context.VoicemailManager"),
)
stack.enter_context(
patch("meshchatx.src.backend.identity_context.RingtoneManager")
patch("meshchatx.src.backend.identity_context.RingtoneManager"),
)
stack.enter_context(patch("meshchatx.src.backend.identity_context.RNCPHandler"))
stack.enter_context(
patch("meshchatx.src.backend.identity_context.RNStatusHandler")
patch("meshchatx.src.backend.identity_context.RNStatusHandler"),
)
stack.enter_context(
patch("meshchatx.src.backend.identity_context.RNProbeHandler")
patch("meshchatx.src.backend.identity_context.RNProbeHandler"),
)
stack.enter_context(
patch("meshchatx.src.backend.identity_context.TranslatorHandler")
patch("meshchatx.src.backend.identity_context.TranslatorHandler"),
)
stack.enter_context(
patch("meshchatx.src.backend.identity_context.CommunityInterfacesManager")
patch("meshchatx.src.backend.identity_context.CommunityInterfacesManager"),
)
stack.enter_context(patch("meshchatx.meshchat.AsyncUtils"))
stack.enter_context(patch("LXMF.LXMRouter"))
@ -76,31 +76,37 @@ def mock_app(temp_dir):
stack.enter_context(patch("threading.Thread"))
stack.enter_context(
patch.object(
ReticulumMeshChat, "announce_loop", new=MagicMock(return_value=None)
)
ReticulumMeshChat,
"announce_loop",
new=MagicMock(return_value=None),
),
)
stack.enter_context(
patch.object(
ReticulumMeshChat,
"announce_sync_propagation_nodes",
new=MagicMock(return_value=None),
)
),
)
stack.enter_context(
patch.object(
ReticulumMeshChat, "crawler_loop", new=MagicMock(return_value=None)
)
ReticulumMeshChat,
"crawler_loop",
new=MagicMock(return_value=None),
),
)
stack.enter_context(
patch.object(
ReticulumMeshChat, "auto_backup_loop", new=MagicMock(return_value=None)
)
ReticulumMeshChat,
"auto_backup_loop",
new=MagicMock(return_value=None),
),
)
mock_id = MockIdentityClass()
mock_id.get_private_key = MagicMock(return_value=b"test_private_key")
stack.enter_context(
patch.object(MockIdentityClass, "from_file", return_value=mock_id)
patch.object(MockIdentityClass, "from_file", return_value=mock_id),
)
app = ReticulumMeshChat(
@ -136,9 +142,10 @@ def mock_app(temp_dir):
data=st.recursive(
st.one_of(st.none(), st.booleans(), st.floats(), st.text(), st.integers()),
lambda children: st.one_of(
st.lists(children), st.dictionaries(st.text(), children)
st.lists(children),
st.dictionaries(st.text(), children),
),
)
),
)
@pytest.mark.asyncio
async def test_websocket_api_recursive_fuzzing(mock_app, data):
@ -190,7 +197,8 @@ async def test_lxm_uri_parsing_fuzzing(mock_app, uri):
# Also test it through the websocket interface if it exists there
mock_client = MagicMock()
await mock_app.on_websocket_data_received(
mock_client, {"type": "lxm.ingest_uri", "uri": uri}
mock_client,
{"type": "lxm.ingest_uri", "uri": uri},
)
except (KeyError, TypeError, ValueError, AttributeError):
pass
@ -232,7 +240,8 @@ def test_lxmf_message_construction_fuzzing(mock_app, content, title, fields):
@given(
table_name=st.sampled_from(["messages", "announces", "identities", "config"]),
data=st.dictionaries(
st.text(), st.one_of(st.text(), st.integers(), st.binary(), st.none())
st.text(),
st.one_of(st.text(), st.integers(), st.binary(), st.none()),
),
)
def test_database_record_fuzzing(mock_app, table_name, data):
@ -266,10 +275,10 @@ def test_database_record_fuzzing(mock_app, table_name, data):
"map_default_lat",
"map_default_lon",
"lxmf_inbound_stamp_cost",
]
],
),
st.one_of(st.text(), st.integers(), st.booleans(), st.none()),
)
),
)
@pytest.mark.asyncio
async def test_config_update_fuzzing(mock_app, config_updates):
@ -288,7 +297,10 @@ async def test_config_update_fuzzing(mock_app, config_updates):
@given(destination_hash=st.text(), content=st.text(), title=st.text())
@pytest.mark.asyncio
async def test_lxm_generate_paper_uri_fuzzing(
mock_app, destination_hash, content, title
mock_app,
destination_hash,
content,
title,
):
"""Fuzz lxm.generate_paper_uri WebSocket handler."""
mock_client = MagicMock()
@ -410,7 +422,10 @@ def test_on_lxmf_delivery_fuzzing(mock_app, content, title):
app_data=st.binary(min_size=0, max_size=1000),
)
def test_on_lxmf_announce_received_fuzzing(
mock_app, aspect, destination_hash, app_data
mock_app,
aspect,
destination_hash,
app_data,
):
"""Fuzz the announce received handler."""
try:
@ -457,7 +472,10 @@ def test_telemeter_roundtrip_fuzzing(battery, uptime, load, temperature):
try:
t = Telemeter(
battery=battery, uptime=uptime, load=load, temperature=temperature
battery=battery,
uptime=uptime,
load=load,
temperature=temperature,
)
packed = t.pack()
unpacked = Telemeter.from_packed(packed)

View file

@ -1,8 +1,9 @@
import os
import shutil
import tempfile
from unittest.mock import AsyncMock, MagicMock, patch
from contextlib import ExitStack
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import RNS
@ -64,17 +65,19 @@ def mock_rns():
# Mock class methods on MockIdentityClass
mock_id_instance = MockIdentityClass()
mock_id_instance.get_private_key = MagicMock(
return_value=b"initial_private_key"
return_value=b"initial_private_key",
)
stack.enter_context(
patch.object(MockIdentityClass, "from_file", return_value=mock_id_instance)
patch.object(MockIdentityClass, "from_file", return_value=mock_id_instance),
)
stack.enter_context(
patch.object(MockIdentityClass, "recall", return_value=mock_id_instance)
patch.object(MockIdentityClass, "recall", return_value=mock_id_instance),
)
stack.enter_context(
patch.object(MockIdentityClass, "from_bytes", return_value=mock_id_instance)
patch.object(
MockIdentityClass, "from_bytes", return_value=mock_id_instance
),
)
# Access specifically the ones we need to configure
@ -118,7 +121,7 @@ async def test_hotswap_identity_success(mock_rns, temp_dir):
# Mock methods
app.teardown_identity = MagicMock()
app.setup_identity = MagicMock(
side_effect=lambda id: setattr(app, "current_context", mock_context)
side_effect=lambda id: setattr(app, "current_context", mock_context),
)
app.websocket_broadcast = AsyncMock()
@ -164,7 +167,7 @@ async def test_hotswap_identity_keep_alive(mock_rns, temp_dir):
# Mock methods
app.teardown_identity = MagicMock()
app.setup_identity = MagicMock(
side_effect=lambda id: setattr(app, "current_context", mock_context)
side_effect=lambda id: setattr(app, "current_context", mock_context),
)
app.websocket_broadcast = AsyncMock()

View file

@ -1,7 +1,8 @@
import unittest
import shutil
import tempfile
import unittest
from pathlib import Path
from meshchatx.src.backend.integrity_manager import IntegrityManager

View file

@ -0,0 +1,201 @@
import json
import shutil
import tempfile
from unittest.mock import MagicMock, patch
import pytest
import RNS
from meshchatx.meshchat import ReticulumMeshChat
class ConfigDict(dict):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.write_called = False
def write(self):
self.write_called = True
return True
@pytest.fixture
def temp_dir():
path = tempfile.mkdtemp()
try:
yield path
finally:
shutil.rmtree(path)
def build_identity():
identity = MagicMock(spec=RNS.Identity)
identity.hash = b"test_hash_32_bytes_long_01234567"
identity.hexhash = identity.hash.hex()
identity.get_private_key.return_value = b"test_private_key"
return identity
async def find_route_handler(app_instance, path, method):
for route in app_instance.get_routes():
if route.path == path and route.method == method:
return route.handler
return None
@pytest.mark.asyncio
async def test_reticulum_discovery_get_and_patch(temp_dir):
config = ConfigDict(
{
"reticulum": {
"discover_interfaces": "true",
"interface_discovery_sources": "abc,def",
"required_discovery_value": "16",
"autoconnect_discovered_interfaces": "2",
"network_identity": "/tmp/net_id",
},
"interfaces": {},
},
)
with (
patch("meshchatx.meshchat.generate_ssl_certificate"),
patch("RNS.Reticulum") as mock_rns,
patch("RNS.Transport"),
patch("LXMF.LXMRouter"),
):
mock_reticulum = mock_rns.return_value
mock_reticulum.config = config
mock_reticulum.configpath = "/tmp/mock_config"
mock_reticulum.is_connected_to_shared_instance = False
mock_reticulum.transport_enabled.return_value = True
app_instance = ReticulumMeshChat(
identity=build_identity(),
storage_dir=temp_dir,
reticulum_config_dir=temp_dir,
)
get_handler = await find_route_handler(
app_instance,
"/api/v1/reticulum/discovery",
"GET",
)
patch_handler = await find_route_handler(
app_instance,
"/api/v1/reticulum/discovery",
"PATCH",
)
assert get_handler and patch_handler
# GET returns current reticulum discovery config
get_response = await get_handler(MagicMock())
get_data = json.loads(get_response.body)
assert get_data["discovery"]["discover_interfaces"] == "true"
assert get_data["discovery"]["interface_discovery_sources"] == "abc,def"
assert get_data["discovery"]["required_discovery_value"] == "16"
assert get_data["discovery"]["autoconnect_discovered_interfaces"] == "2"
assert get_data["discovery"]["network_identity"] == "/tmp/net_id"
# PATCH updates and persists
new_config = {
"discover_interfaces": False,
"interface_discovery_sources": "",
"required_discovery_value": 18,
"autoconnect_discovered_interfaces": 5,
"network_identity": "/tmp/other_id",
}
class PatchRequest:
@staticmethod
async def json():
return new_config
patch_response = await patch_handler(PatchRequest())
patch_data = json.loads(patch_response.body)
assert patch_data["discovery"]["discover_interfaces"] is False
assert patch_data["discovery"]["interface_discovery_sources"] is None
assert patch_data["discovery"]["required_discovery_value"] == 18
assert patch_data["discovery"]["autoconnect_discovered_interfaces"] == 5
assert patch_data["discovery"]["network_identity"] == "/tmp/other_id"
assert config["reticulum"]["discover_interfaces"] is False
assert "interface_discovery_sources" not in config["reticulum"]
assert config["reticulum"]["required_discovery_value"] == 18
assert config["reticulum"]["autoconnect_discovered_interfaces"] == 5
assert config["reticulum"]["network_identity"] == "/tmp/other_id"
assert config.write_called
@pytest.mark.asyncio
async def test_interface_add_includes_discovery_fields(temp_dir):
config = ConfigDict({"reticulum": {}, "interfaces": {}})
with (
patch("meshchatx.meshchat.generate_ssl_certificate"),
patch("RNS.Reticulum") as mock_rns,
patch("RNS.Transport"),
patch("LXMF.LXMRouter"),
):
mock_reticulum = mock_rns.return_value
mock_reticulum.config = config
mock_reticulum.configpath = "/tmp/mock_config"
mock_reticulum.is_connected_to_shared_instance = False
mock_reticulum.transport_enabled.return_value = True
app_instance = ReticulumMeshChat(
identity=build_identity(),
storage_dir=temp_dir,
reticulum_config_dir=temp_dir,
)
add_handler = await find_route_handler(
app_instance,
"/api/v1/reticulum/interfaces/add",
"POST",
)
assert add_handler
payload = {
"allow_overwriting_interface": False,
"name": "TestIface",
"type": "TCPClientInterface",
"target_host": "example.com",
"target_port": "4242",
"discoverable": "yes",
"discovery_name": "Region A",
"announce_interval": 720,
"reachable_on": "/usr/bin/get_ip.sh",
"discovery_stamp_value": 22,
"discovery_encrypt": True,
"publish_ifac": True,
"latitude": 10.1,
"longitude": 20.2,
"height": 30,
"discovery_frequency": 915000000,
"discovery_bandwidth": 125000,
"discovery_modulation": "LoRa",
}
class AddRequest:
@staticmethod
async def json():
return payload
response = await add_handler(AddRequest())
data = json.loads(response.body)
assert "Interface has been added" in data["message"]
saved = config["interfaces"]["TestIface"]
assert saved["discoverable"] == "yes"
assert saved["discovery_name"] == "Region A"
assert saved["announce_interval"] == 720
assert saved["reachable_on"] == "/usr/bin/get_ip.sh"
assert saved["discovery_stamp_value"] == 22
assert saved["discovery_encrypt"] is True
assert saved["publish_ifac"] is True
assert saved["latitude"] == 10.1
assert saved["longitude"] == 20.2
assert saved["height"] == 30
assert saved["discovery_frequency"] == 915000000
assert saved["discovery_bandwidth"] == 125000
assert saved["discovery_modulation"] == "LoRa"
assert config.write_called

View file

@ -1,4 +1,5 @@
import json
from meshchatx.src.backend.meshchat_utils import message_fields_have_attachments
@ -22,7 +23,7 @@ def test_message_fields_have_attachments():
# File attachments - with files
assert (
message_fields_have_attachments(
json.dumps({"file_attachments": [{"file_name": "test.txt"}]})
json.dumps({"file_attachments": [{"file_name": "test.txt"}]}),
)
is True
)
@ -36,8 +37,8 @@ def test_message_fields_have_attachments_mixed():
assert (
message_fields_have_attachments(
json.dumps(
{"image": "img", "file_attachments": [{"file_name": "test.txt"}]}
)
{"image": "img", "file_attachments": [{"file_name": "test.txt"}]},
),
)
is True
)
@ -45,7 +46,7 @@ def test_message_fields_have_attachments_mixed():
# Unrelated fields
assert (
message_fields_have_attachments(
json.dumps({"title": "hello", "content": "world"})
json.dumps({"title": "hello", "content": "world"}),
)
is False
)

View file

@ -1,10 +1,11 @@
import shutil
import tempfile
from unittest.mock import AsyncMock, MagicMock, patch
from contextlib import ExitStack
from unittest.mock import AsyncMock, MagicMock, patch
import LXMF
import pytest
import RNS
import LXMF
from meshchatx.meshchat import ReticulumMeshChat
@ -80,17 +81,19 @@ def mock_rns():
# Mock class methods on MockIdentityClass
mock_id_instance = MockIdentityClass()
mock_id_instance.get_private_key = MagicMock(
return_value=b"initial_private_key"
return_value=b"initial_private_key",
)
stack.enter_context(
patch.object(MockIdentityClass, "from_file", return_value=mock_id_instance)
patch.object(MockIdentityClass, "from_file", return_value=mock_id_instance),
)
stack.enter_context(
patch.object(MockIdentityClass, "recall", return_value=mock_id_instance)
patch.object(MockIdentityClass, "recall", return_value=mock_id_instance),
)
stack.enter_context(
patch.object(MockIdentityClass, "from_bytes", return_value=mock_id_instance)
patch.object(
MockIdentityClass, "from_bytes", return_value=mock_id_instance
),
)
# Setup mock LXMessage
@ -249,7 +252,7 @@ async def test_receive_message_updates_icon(mock_rns, temp_dir):
"new_icon",
b"\xff\xff\xff", # #ffffff
b"\x00\x00\x00", # #000000
]
],
}
# Mock methods

View file

@ -1,11 +1,13 @@
import json
import shutil
import tempfile
import pytest
import json
from unittest.mock import MagicMock, patch
from meshchatx.meshchat import ReticulumMeshChat
import RNS
import LXMF
import pytest
import RNS
from meshchatx.meshchat import ReticulumMeshChat
# Store original constants
PR_IDLE = LXMF.LXMRouter.PR_IDLE
@ -58,7 +60,7 @@ def mock_app(temp_dir):
mock_rns_inst.transport_enabled.return_value = False
with patch(
"meshchatx.src.backend.meshchat_utils.LXMRouter"
"meshchatx.src.backend.meshchat_utils.LXMRouter",
) as mock_utils_router:
mock_utils_router.PR_IDLE = PR_IDLE
mock_utils_router.PR_PATH_REQUESTED = PR_PATH_REQUESTED
@ -76,7 +78,9 @@ def mock_app(temp_dir):
app.current_context.message_router = mock_router
with patch.object(
app, "send_config_to_websocket_clients", return_value=None
app,
"send_config_to_websocket_clients",
return_value=None,
):
yield app
@ -87,11 +91,11 @@ async def test_lxmf_propagation_config(mock_app):
node_hash_bytes = bytes.fromhex(node_hash_hex)
await mock_app.update_config(
{"lxmf_preferred_propagation_node_destination_hash": node_hash_hex}
{"lxmf_preferred_propagation_node_destination_hash": node_hash_hex},
)
mock_app.current_context.message_router.set_outbound_propagation_node.assert_called_with(
node_hash_bytes
node_hash_bytes,
)
assert (
mock_app.config.lxmf_preferred_propagation_node_destination_hash.get()
@ -159,7 +163,7 @@ async def test_send_failed_via_prop_node(mock_app):
@pytest.mark.asyncio
async def test_auto_sync_interval_config(mock_app):
await mock_app.update_config(
{"lxmf_preferred_propagation_node_auto_sync_interval_seconds": 3600}
{"lxmf_preferred_propagation_node_auto_sync_interval_seconds": 3600},
)
assert (
mock_app.config.lxmf_preferred_propagation_node_auto_sync_interval_seconds.get()
@ -198,17 +202,17 @@ async def test_user_provided_node_hash(mock_app):
# Set this node as preferred
await mock_app.update_config(
{"lxmf_preferred_propagation_node_destination_hash": node_hash_hex}
{"lxmf_preferred_propagation_node_destination_hash": node_hash_hex},
)
# Check if the router was updated with the correct bytes
mock_app.current_context.message_router.set_outbound_propagation_node.assert_called_with(
bytes.fromhex(node_hash_hex)
bytes.fromhex(node_hash_hex),
)
# Trigger a sync request
mock_app.current_context.message_router.get_outbound_propagation_node.return_value = bytes.fromhex(
node_hash_hex
node_hash_hex,
)
sync_handler = next(
r.handler
@ -219,5 +223,5 @@ async def test_user_provided_node_hash(mock_app):
# Verify the router was told to sync for our identity
mock_app.current_context.message_router.request_messages_from_propagation_node.assert_called_with(
mock_app.current_context.identity
mock_app.current_context.identity,
)

View file

@ -1,11 +1,13 @@
import json
import shutil
import tempfile
import pytest
import json
from unittest.mock import MagicMock, patch
from meshchatx.meshchat import ReticulumMeshChat
import RNS
import LXMF
import pytest
import RNS
from meshchatx.meshchat import ReticulumMeshChat
# Store original constants
PR_IDLE = LXMF.LXMRouter.PR_IDLE
@ -48,7 +50,7 @@ def mock_app(temp_dir):
mock_rns_inst.transport_enabled.return_value = False
with patch(
"meshchatx.src.backend.meshchat_utils.LXMRouter"
"meshchatx.src.backend.meshchat_utils.LXMRouter",
) as mock_utils_router:
mock_utils_router.PR_IDLE = PR_IDLE
mock_utils_router.PR_COMPLETE = PR_COMPLETE
@ -62,7 +64,9 @@ def mock_app(temp_dir):
app.current_context.message_router = mock_router
with patch.object(
app, "send_config_to_websocket_clients", return_value=None
app,
"send_config_to_websocket_clients",
return_value=None,
):
yield app
@ -117,13 +121,13 @@ async def test_specific_node_hash_validation(mock_app):
with patch.object(mock_app, "send_config_to_websocket_clients", return_value=None):
# Set the preferred propagation node
await mock_app.update_config(
{"lxmf_preferred_propagation_node_destination_hash": node_hash_hex}
{"lxmf_preferred_propagation_node_destination_hash": node_hash_hex},
)
# Verify it was set on the router correctly as 16 bytes
expected_bytes = bytes.fromhex(node_hash_hex)
mock_app.current_context.message_router.set_outbound_propagation_node.assert_called_with(
expected_bytes
expected_bytes,
)
# Trigger sync

View file

@ -3,10 +3,11 @@ import json
from unittest.mock import MagicMock
import LXMF
from meshchatx.src.backend.lxmf_utils import (
convert_db_lxmf_message_to_dict,
convert_lxmf_message_to_dict,
convert_lxmf_state_to_string,
convert_db_lxmf_message_to_dict,
)
@ -129,9 +130,9 @@ def test_convert_db_lxmf_message_to_dict():
{
"file_name": "f.txt",
"file_bytes": base64.b64encode(b"file").decode(),
}
},
],
}
},
),
"timestamp": 1234567890,
"rssi": -60,

View file

@ -1,10 +1,11 @@
import os
import shutil
import tempfile
import sqlite3
import tempfile
from unittest.mock import MagicMock, patch
import pytest
from meshchatx.src.backend.map_manager import MapManager
@ -83,11 +84,12 @@ def test_get_tile(mock_config, temp_dir):
conn = sqlite3.connect(db_path)
conn.execute(
"CREATE TABLE tiles (zoom_level integer, tile_column integer, tile_row integer, tile_data blob)"
"CREATE TABLE tiles (zoom_level integer, tile_column integer, tile_row integer, tile_data blob)",
)
# Zoom 0, Tile 0,0. TMS y for 0/0/0 is (1<<0)-1-0 = 0
conn.execute(
"INSERT INTO tiles VALUES (0, 0, 0, ?)", (sqlite3.Binary(b"tile_data"),)
"INSERT INTO tiles VALUES (0, 0, 0, ?)",
(sqlite3.Binary(b"tile_data"),),
)
conn.commit()
conn.close()

View file

@ -1,4 +1,5 @@
import unittest
from meshchatx.src.backend.markdown_renderer import MarkdownRenderer
@ -24,7 +25,7 @@ class TestMarkdownRenderer(unittest.TestCase):
# Check for escaped characters
self.assertTrue(
"print(&#x27;hello&#x27;)" in rendered
or "print(&#039;hello&#039;)" in rendered
or "print(&#039;hello&#039;)" in rendered,
)
def test_lists(self):

View file

@ -1,8 +1,9 @@
import unittest
import os
import secrets
import shutil
import tempfile
import secrets
import unittest
from meshchatx.src.backend.database import Database
from meshchatx.src.backend.identity_manager import IdentityManager
from tests.backend.benchmarking_utils import MemoryTracker
@ -54,7 +55,9 @@ class TestMemoryProfiling(unittest.TestCase):
# 10k messages * 512 bytes is ~5MB of raw content.
# SQLite should handle this efficiently.
self.assertLess(
tracker.mem_delta, 20.0, "Excessive memory growth during DB insertion"
tracker.mem_delta,
20.0,
"Excessive memory growth during DB insertion",
)
def test_identity_manager_memory(self):
@ -70,7 +73,9 @@ class TestMemoryProfiling(unittest.TestCase):
self.assertEqual(len(identities), 50)
self.assertLess(
tracker.mem_delta, 10.0, "Identity management consumed too much memory"
tracker.mem_delta,
10.0,
"Identity management consumed too much memory",
)
def test_large_message_processing(self):
@ -124,7 +129,9 @@ class TestMemoryProfiling(unittest.TestCase):
self.db.announces.upsert_announce(data)
self.assertLess(
tracker.mem_delta, 15.0, "Announce updates causing memory bloat"
tracker.mem_delta,
15.0,
"Announce updates causing memory bloat",
)

View file

@ -1,8 +1,8 @@
import os
import shutil
import tempfile
from unittest.mock import MagicMock, patch
from contextlib import ExitStack
from unittest.mock import MagicMock, patch
import pytest
import RNS
@ -31,36 +31,36 @@ def mock_app(temp_dir):
with ExitStack() as stack:
stack.enter_context(patch("meshchatx.src.backend.identity_context.Database"))
stack.enter_context(
patch("meshchatx.src.backend.identity_context.ConfigManager")
patch("meshchatx.src.backend.identity_context.ConfigManager"),
)
stack.enter_context(
patch("meshchatx.src.backend.identity_context.MessageHandler")
patch("meshchatx.src.backend.identity_context.MessageHandler"),
)
stack.enter_context(
patch("meshchatx.src.backend.identity_context.AnnounceManager")
patch("meshchatx.src.backend.identity_context.AnnounceManager"),
)
stack.enter_context(
patch("meshchatx.src.backend.identity_context.ArchiverManager")
patch("meshchatx.src.backend.identity_context.ArchiverManager"),
)
stack.enter_context(patch("meshchatx.src.backend.identity_context.MapManager"))
stack.enter_context(
patch("meshchatx.src.backend.identity_context.TelephoneManager")
patch("meshchatx.src.backend.identity_context.TelephoneManager"),
)
stack.enter_context(
patch("meshchatx.src.backend.identity_context.VoicemailManager")
patch("meshchatx.src.backend.identity_context.VoicemailManager"),
)
stack.enter_context(
patch("meshchatx.src.backend.identity_context.RingtoneManager")
patch("meshchatx.src.backend.identity_context.RingtoneManager"),
)
stack.enter_context(patch("meshchatx.src.backend.identity_context.RNCPHandler"))
stack.enter_context(
patch("meshchatx.src.backend.identity_context.RNStatusHandler")
patch("meshchatx.src.backend.identity_context.RNStatusHandler"),
)
stack.enter_context(
patch("meshchatx.src.backend.identity_context.RNProbeHandler")
patch("meshchatx.src.backend.identity_context.RNProbeHandler"),
)
stack.enter_context(
patch("meshchatx.src.backend.identity_context.TranslatorHandler")
patch("meshchatx.src.backend.identity_context.TranslatorHandler"),
)
stack.enter_context(patch("LXMF.LXMRouter"))
stack.enter_context(patch("RNS.Identity", MockIdentityClass))
@ -72,34 +72,34 @@ def mock_app(temp_dir):
ReticulumMeshChat,
"announce_loop",
new=MagicMock(return_value=None),
)
),
)
stack.enter_context(
patch.object(
ReticulumMeshChat,
"announce_sync_propagation_nodes",
new=MagicMock(return_value=None),
)
),
)
stack.enter_context(
patch.object(
ReticulumMeshChat,
"crawler_loop",
new=MagicMock(return_value=None),
)
),
)
mock_id = MockIdentityClass()
mock_id.get_private_key = MagicMock(return_value=b"test_private_key")
stack.enter_context(
patch.object(MockIdentityClass, "from_file", return_value=mock_id)
patch.object(MockIdentityClass, "from_file", return_value=mock_id),
)
stack.enter_context(
patch.object(MockIdentityClass, "recall", return_value=mock_id)
patch.object(MockIdentityClass, "recall", return_value=mock_id),
)
stack.enter_context(
patch.object(MockIdentityClass, "from_bytes", return_value=mock_id)
patch.object(MockIdentityClass, "from_bytes", return_value=mock_id),
)
app = ReticulumMeshChat(

View file

@ -1,5 +1,6 @@
import unittest
from unittest.mock import MagicMock
from meshchatx.src.backend.message_handler import MessageHandler

View file

@ -1,5 +1,6 @@
import unittest
from unittest.mock import MagicMock
from meshchatx.src.backend.nomadnet_downloader import NomadnetDownloader

View file

@ -1,7 +1,7 @@
import os
import time
from unittest.mock import MagicMock, patch
from contextlib import ExitStack
from unittest.mock import MagicMock, patch
import pytest
import RNS
@ -49,33 +49,33 @@ def mock_app(db, tmp_path):
stack.enter_context(patch("RNS.Transport"))
stack.enter_context(patch("LXMF.LXMRouter"))
stack.enter_context(
patch("meshchatx.src.backend.identity_context.TelephoneManager")
patch("meshchatx.src.backend.identity_context.TelephoneManager"),
)
stack.enter_context(
patch("meshchatx.src.backend.identity_context.VoicemailManager")
patch("meshchatx.src.backend.identity_context.VoicemailManager"),
)
stack.enter_context(
patch("meshchatx.src.backend.identity_context.RingtoneManager")
patch("meshchatx.src.backend.identity_context.RingtoneManager"),
)
stack.enter_context(patch("meshchatx.src.backend.identity_context.RNCPHandler"))
stack.enter_context(
patch("meshchatx.src.backend.identity_context.RNStatusHandler")
patch("meshchatx.src.backend.identity_context.RNStatusHandler"),
)
stack.enter_context(
patch("meshchatx.src.backend.identity_context.RNProbeHandler")
patch("meshchatx.src.backend.identity_context.RNProbeHandler"),
)
stack.enter_context(
patch("meshchatx.src.backend.identity_context.TranslatorHandler")
patch("meshchatx.src.backend.identity_context.TranslatorHandler"),
)
stack.enter_context(
patch("meshchatx.src.backend.identity_context.ArchiverManager")
patch("meshchatx.src.backend.identity_context.ArchiverManager"),
)
stack.enter_context(patch("meshchatx.src.backend.identity_context.MapManager"))
stack.enter_context(
patch("meshchatx.src.backend.identity_context.MessageHandler")
patch("meshchatx.src.backend.identity_context.MessageHandler"),
)
stack.enter_context(
patch("meshchatx.src.backend.identity_context.AnnounceManager")
patch("meshchatx.src.backend.identity_context.AnnounceManager"),
)
stack.enter_context(patch("threading.Thread"))
@ -83,44 +83,52 @@ def mock_app(db, tmp_path):
mock_id.get_private_key = MagicMock(return_value=b"test_private_key")
stack.enter_context(
patch.object(MockIdentityClass, "from_file", return_value=mock_id)
patch.object(MockIdentityClass, "from_file", return_value=mock_id),
)
stack.enter_context(
patch.object(MockIdentityClass, "recall", return_value=mock_id)
patch.object(MockIdentityClass, "recall", return_value=mock_id),
)
stack.enter_context(
patch.object(MockIdentityClass, "from_bytes", return_value=mock_id)
patch.object(MockIdentityClass, "from_bytes", return_value=mock_id),
)
# Patch background threads and other heavy init
stack.enter_context(
patch.object(
ReticulumMeshChat, "announce_loop", new=MagicMock(return_value=None)
)
ReticulumMeshChat,
"announce_loop",
new=MagicMock(return_value=None),
),
)
stack.enter_context(
patch.object(
ReticulumMeshChat,
"announce_sync_propagation_nodes",
new=MagicMock(return_value=None),
)
),
)
stack.enter_context(
patch.object(
ReticulumMeshChat, "crawler_loop", new=MagicMock(return_value=None)
)
ReticulumMeshChat,
"crawler_loop",
new=MagicMock(return_value=None),
),
)
stack.enter_context(
patch.object(
ReticulumMeshChat, "auto_backup_loop", new=MagicMock(return_value=None)
)
ReticulumMeshChat,
"auto_backup_loop",
new=MagicMock(return_value=None),
),
)
# Prevent JSON serialization issues with MagicMocks
stack.enter_context(
patch.object(
ReticulumMeshChat, "send_config_to_websocket_clients", return_value=None
)
ReticulumMeshChat,
"send_config_to_websocket_clients",
return_value=None,
),
)
app = ReticulumMeshChat(
@ -262,7 +270,10 @@ async def test_notifications_api(mock_app):
# Let's test a spike of notifications
for i in range(100):
mock_app.database.misc.add_notification(
f"type{i}", f"hash{i}", f"title{i}", f"content{i}"
f"type{i}",
f"hash{i}",
f"title{i}",
f"content{i}",
)
notifications = mock_app.database.misc.get_notifications(limit=50)
@ -295,7 +306,10 @@ def test_voicemail_notification_fuzzing(mock_app, remote_hash, remote_name, dura
call_was_established=st.booleans(),
)
def test_missed_call_notification_fuzzing(
mock_app, remote_hash, status_code, call_was_established
mock_app,
remote_hash,
status_code,
call_was_established,
):
"""Fuzz missed call notification triggering."""
mock_app.database.misc.provider.execute("DELETE FROM notifications")

View file

@ -1,12 +1,13 @@
import unittest
import os
import secrets
import shutil
import tempfile
import time
import secrets
import unittest
from unittest.mock import MagicMock
from meshchatx.src.backend.database import Database
from meshchatx.src.backend.announce_manager import AnnounceManager
from meshchatx.src.backend.database import Database
class TestPerformanceBottlenecks(unittest.TestCase):
@ -60,7 +61,9 @@ class TestPerformanceBottlenecks(unittest.TestCase):
for offset in offsets:
start = time.time()
msgs = self.db.messages.get_conversation_messages(
peer_hash, limit=limit, offset=offset
peer_hash,
limit=limit,
offset=offset,
)
duration = (time.time() - start) * 1000
print(f"Fetch {limit} messages at offset {offset}: {duration:.2f}ms")
@ -103,7 +106,7 @@ class TestPerformanceBottlenecks(unittest.TestCase):
duration_total = time.time() - start_total
avg_duration = (duration_total / num_announces) * 1000
print(
f"Processed {num_announces} announces in {duration_total:.2f}s (Avg: {avg_duration:.2f}ms/announce)"
f"Processed {num_announces} announces in {duration_total:.2f}s (Avg: {avg_duration:.2f}ms/announce)",
)
self.assertLess(avg_duration, 20, "Announce processing is too slow!")
@ -129,7 +132,9 @@ class TestPerformanceBottlenecks(unittest.TestCase):
# Benchmark filtered search with pagination
start = time.time()
results = self.announce_manager.get_filtered_announces(
aspect="lxmf.delivery", limit=50, offset=1000
aspect="lxmf.delivery",
limit=50,
offset=1000,
)
duration = (time.time() - start) * 1000
print(f"Filtered announce pagination (offset 1000): {duration:.2f}ms")
@ -164,7 +169,7 @@ class TestPerformanceBottlenecks(unittest.TestCase):
]
print(
f"\nRunning {num_threads} threads inserting {announces_per_thread} announces each..."
f"\nRunning {num_threads} threads inserting {announces_per_thread} announces each...",
)
start = time.time()
for t in threads:
@ -174,7 +179,7 @@ class TestPerformanceBottlenecks(unittest.TestCase):
duration = time.time() - start
print(
f"Concurrent insertion took {duration:.2f}s for {num_threads * announces_per_thread} announces"
f"Concurrent insertion took {duration:.2f}s for {num_threads * announces_per_thread} announces",
)
self.assertLess(duration, 10.0, "Concurrent announce insertion is too slow!")

View file

@ -1,11 +1,13 @@
import json
import shutil
import tempfile
import pytest
import json
from unittest.mock import MagicMock, patch
from meshchatx.meshchat import ReticulumMeshChat
import pytest
import RNS
from meshchatx.meshchat import ReticulumMeshChat
@pytest.fixture
def temp_dir():

View file

@ -5,6 +5,7 @@ from unittest.mock import MagicMock, patch
import pytest
import RNS
from meshchatx.src.backend.rncp_handler import RNCPHandler
@ -40,7 +41,9 @@ def mock_rns():
patch.object(MockIdentityClass, "from_file", return_value=mock_id_instance),
patch.object(MockIdentityClass, "recall", return_value=mock_id_instance),
patch.object(
MockIdentityClass, "from_bytes", return_value=mock_id_instance
MockIdentityClass,
"from_bytes",
return_value=mock_id_instance,
),
):
mock_dest_instance = MagicMock()
@ -85,7 +88,9 @@ def test_setup_receive_destination(mock_rns, temp_dir):
mock_rns["Reticulum"].identitypath = temp_dir
_ = handler.setup_receive_destination(
allowed_hashes=["abc123def456"], fetch_allowed=True, fetch_jail=temp_dir
allowed_hashes=["abc123def456"],
fetch_allowed=True,
fetch_jail=temp_dir,
)
assert handler.receive_destination is not None

View file

@ -1,9 +1,11 @@
import pytest
import json
from unittest.mock import MagicMock, patch, AsyncMock
from meshchatx.meshchat import ReticulumMeshChat
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import RNS
from meshchatx.meshchat import ReticulumMeshChat
@pytest.fixture
def temp_dir(tmp_path):

View file

@ -45,7 +45,9 @@ def mock_rns():
new=MagicMock(return_value=None),
),
patch.object(
ReticulumMeshChat, "send_config_to_websocket_clients", return_value=None
ReticulumMeshChat,
"send_config_to_websocket_clients",
return_value=None,
),
):
# Setup mock instance
@ -57,10 +59,14 @@ def mock_rns():
patch.object(MockIdentityClass, "from_file", return_value=mock_id_instance),
patch.object(MockIdentityClass, "recall", return_value=mock_id_instance),
patch.object(
MockIdentityClass, "from_bytes", return_value=mock_id_instance
MockIdentityClass,
"from_bytes",
return_value=mock_id_instance,
),
patch.object(
MockIdentityClass, "full_hash", return_value=b"full_hash_bytes"
MockIdentityClass,
"full_hash",
return_value=b"full_hash_bytes",
),
):
# Setup mock transport
@ -263,7 +269,7 @@ async def test_hotswap_identity(mock_rns, temp_dir):
with (
patch("meshchatx.src.backend.identity_context.Database"),
patch(
"meshchatx.src.backend.identity_context.ConfigManager"
"meshchatx.src.backend.identity_context.ConfigManager",
) as mock_config_class,
patch("meshchatx.src.backend.identity_context.MessageHandler"),
patch("meshchatx.src.backend.identity_context.AnnounceManager"),

View file

@ -1,8 +1,10 @@
import pytest
from unittest.mock import MagicMock, patch
from meshchatx.src.backend.rnstatus_handler import RNStatusHandler
import pytest
import RNS
from meshchatx.src.backend.rnstatus_handler import RNStatusHandler
@pytest.fixture
def mock_reticulum_instance():
@ -50,7 +52,7 @@ def test_blackhole_status_missing_api(mock_reticulum_instance):
# But we can patch the RNS object inside rnstatus_handler module.
with patch(
"meshchatx.src.backend.rnstatus_handler.RNS.Reticulum"
"meshchatx.src.backend.rnstatus_handler.RNS.Reticulum",
) as mock_rns_class:
del mock_rns_class.publish_blackhole_enabled

View file

@ -1,6 +1,6 @@
import base64
import os
import time
import base64
from contextlib import ExitStack
from unittest.mock import MagicMock, patch
@ -27,39 +27,39 @@ def mock_app():
# Mock core dependencies that interact with the system/network
stack.enter_context(patch("meshchatx.src.backend.identity_context.Database"))
stack.enter_context(
patch("meshchatx.src.backend.identity_context.ConfigManager")
patch("meshchatx.src.backend.identity_context.ConfigManager"),
)
stack.enter_context(
patch("meshchatx.src.backend.identity_context.MessageHandler")
patch("meshchatx.src.backend.identity_context.MessageHandler"),
)
stack.enter_context(
patch("meshchatx.src.backend.identity_context.AnnounceManager")
patch("meshchatx.src.backend.identity_context.AnnounceManager"),
)
stack.enter_context(
patch("meshchatx.src.backend.identity_context.ArchiverManager")
patch("meshchatx.src.backend.identity_context.ArchiverManager"),
)
stack.enter_context(patch("meshchatx.src.backend.identity_context.MapManager"))
stack.enter_context(
patch("meshchatx.src.backend.identity_context.TelephoneManager")
patch("meshchatx.src.backend.identity_context.TelephoneManager"),
)
stack.enter_context(
patch("meshchatx.src.backend.identity_context.VoicemailManager")
patch("meshchatx.src.backend.identity_context.VoicemailManager"),
)
stack.enter_context(
patch("meshchatx.src.backend.identity_context.RingtoneManager")
patch("meshchatx.src.backend.identity_context.RingtoneManager"),
)
stack.enter_context(patch("meshchatx.src.backend.identity_context.RNCPHandler"))
stack.enter_context(
patch("meshchatx.src.backend.identity_context.RNStatusHandler")
patch("meshchatx.src.backend.identity_context.RNStatusHandler"),
)
stack.enter_context(
patch("meshchatx.src.backend.identity_context.RNProbeHandler")
patch("meshchatx.src.backend.identity_context.RNProbeHandler"),
)
stack.enter_context(
patch("meshchatx.src.backend.identity_context.TranslatorHandler")
patch("meshchatx.src.backend.identity_context.TranslatorHandler"),
)
stack.enter_context(
patch("meshchatx.src.backend.identity_context.CommunityInterfacesManager")
patch("meshchatx.src.backend.identity_context.CommunityInterfacesManager"),
)
mock_async_utils = stack.enter_context(patch("meshchatx.meshchat.AsyncUtils"))
@ -72,36 +72,40 @@ def mock_app():
# Stop background loops
stack.enter_context(
patch.object(ReticulumMeshChat, "announce_loop", return_value=None)
patch.object(ReticulumMeshChat, "announce_loop", return_value=None),
)
stack.enter_context(
patch.object(
ReticulumMeshChat, "announce_sync_propagation_nodes", return_value=None
)
ReticulumMeshChat,
"announce_sync_propagation_nodes",
return_value=None,
),
)
stack.enter_context(
patch.object(ReticulumMeshChat, "crawler_loop", return_value=None)
patch.object(ReticulumMeshChat, "crawler_loop", return_value=None),
)
stack.enter_context(
patch.object(ReticulumMeshChat, "auto_backup_loop", return_value=None)
patch.object(ReticulumMeshChat, "auto_backup_loop", return_value=None),
)
stack.enter_context(
patch.object(
ReticulumMeshChat, "send_config_to_websocket_clients", return_value=None
)
ReticulumMeshChat,
"send_config_to_websocket_clients",
return_value=None,
),
)
mock_id = MockIdentityClass()
mock_id.get_private_key = MagicMock(return_value=b"test_private_key")
stack.enter_context(
patch.object(MockIdentityClass, "from_file", return_value=mock_id)
patch.object(MockIdentityClass, "from_file", return_value=mock_id),
)
stack.enter_context(
patch.object(MockIdentityClass, "recall", return_value=mock_id)
patch.object(MockIdentityClass, "recall", return_value=mock_id),
)
stack.enter_context(
patch.object(MockIdentityClass, "from_bytes", return_value=mock_id)
patch.object(MockIdentityClass, "from_bytes", return_value=mock_id),
)
def mock_run_async(coro):
@ -117,10 +121,10 @@ def mock_app():
return MagicMock()
mock_telephone_manager = stack.enter_context(
patch("meshchatx.src.backend.identity_context.TelephoneManager")
patch("meshchatx.src.backend.identity_context.TelephoneManager"),
)
mock_telephone_manager.return_value.initiate = MagicMock(
side_effect=mock_initiate
side_effect=mock_initiate,
)
app = ReticulumMeshChat(
@ -1015,7 +1019,11 @@ def test_telemetry_unpack_location_fuzzing(mock_app, packed_location):
bearing=st.one_of(st.floats(allow_nan=True, allow_infinity=True), st.integers()),
accuracy=st.one_of(st.floats(allow_nan=True, allow_infinity=True), st.integers()),
last_update=st.one_of(
st.integers(), st.floats(), st.text(), st.binary(), st.none()
st.integers(),
st.floats(),
st.text(),
st.binary(),
st.none(),
),
)
def test_telemetry_pack_location_fuzzing(
@ -1052,12 +1060,15 @@ def test_telemetry_pack_location_fuzzing(
st.none(),
),
data=st.one_of(
st.text(), st.binary(), st.dictionaries(keys=st.text(), values=st.text())
st.text(),
st.binary(),
st.dictionaries(keys=st.text(), values=st.text()),
),
received_from=st.one_of(st.text(), st.binary(), st.none()),
physical_link=st.one_of(
st.dictionaries(
keys=st.text(), values=st.one_of(st.integers(), st.floats(), st.text())
keys=st.text(),
values=st.one_of(st.integers(), st.floats(), st.text()),
),
st.text(),
st.binary(),
@ -1456,7 +1467,9 @@ def test_lxst_audio_frame_handling_fuzzing(mock_app, audio_frame):
caller_identity_hash=st.binary(min_size=0, max_size=100),
)
def test_lxst_call_state_transitions_fuzzing(
mock_app, call_status, caller_identity_hash
mock_app,
call_status,
caller_identity_hash,
):
"""Fuzz LXST call state transitions with invalid states."""
try:
@ -1490,7 +1503,7 @@ def test_lxst_call_state_transitions_fuzzing(
"2400",
"3200",
"invalid",
]
],
),
)
def test_codec2_decode_fuzzing(mock_app, codec2_data, codec_mode):
@ -1577,7 +1590,8 @@ def test_lxst_profile_switching_fuzzing(mock_app, profile_id):
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
@given(
destination_hash=st.one_of(
st.binary(min_size=0, max_size=100), st.text(min_size=0, max_size=100)
st.binary(min_size=0, max_size=100),
st.text(min_size=0, max_size=100),
),
timeout=st.one_of(
st.integers(min_value=-100, max_value=1000),
@ -1612,7 +1626,8 @@ def test_lxst_call_initiation_fuzzing(mock_app, destination_hash, timeout):
loop.run_until_complete(
mock_app.telephone_manager.initiate(
dest_hash_bytes, timeout_seconds=timeout_int
dest_hash_bytes,
timeout_seconds=timeout_int,
),
)
finally:
@ -1716,16 +1731,21 @@ def test_lxmf_message_unpacking_fuzzing(mock_app, lxmf_message_data):
pipeline_config=st.dictionaries(
keys=st.text(),
values=st.one_of(
st.text(), st.binary(), st.integers(), st.floats(), st.booleans(), st.none()
st.text(),
st.binary(),
st.integers(),
st.floats(),
st.booleans(),
st.none(),
),
),
)
def test_lxst_pipeline_config_fuzzing(mock_app, pipeline_config):
"""Fuzz LXST Pipeline configuration."""
from LXST.Pipeline import Pipeline
from LXST.Codecs import Null
from LXST.Sources import Source
from LXST.Pipeline import Pipeline
from LXST.Sinks import Sink
from LXST.Sources import Source
class DummySource(Source):
pass
@ -1748,7 +1768,8 @@ def test_lxst_pipeline_config_fuzzing(mock_app, pipeline_config):
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
@given(
sink_data=st.one_of(
st.binary(min_size=0, max_size=10000), st.text(min_size=0, max_size=1000)
st.binary(min_size=0, max_size=10000),
st.text(min_size=0, max_size=1000),
),
)
def test_lxst_sink_handling_fuzzing(mock_app, sink_data):
@ -1784,7 +1805,8 @@ def test_telemetry_packing_invariants_regression():
}
packed = Telemeter.pack(
time_utc=original_data["time"]["utc"], location=original_data["location"]
time_utc=original_data["time"]["utc"],
location=original_data["location"],
)
unpacked = Telemeter.from_packed(packed)

View file

@ -34,12 +34,16 @@ def mock_rns():
patch("meshchatx.meshchat.get_file_path", return_value="/tmp/mock_path"),
patch.object(ReticulumMeshChat, "announce_loop", return_value=None),
patch.object(
ReticulumMeshChat, "announce_sync_propagation_nodes", return_value=None
ReticulumMeshChat,
"announce_sync_propagation_nodes",
return_value=None,
),
patch.object(ReticulumMeshChat, "crawler_loop", return_value=None),
patch.object(ReticulumMeshChat, "auto_backup_loop", return_value=None),
patch.object(
ReticulumMeshChat, "send_config_to_websocket_clients", return_value=None
ReticulumMeshChat,
"send_config_to_websocket_clients",
return_value=None,
),
):
# Setup mock instance
@ -51,7 +55,9 @@ def mock_rns():
patch.object(MockIdentityClass, "from_file", return_value=mock_id_instance),
patch.object(MockIdentityClass, "recall", return_value=mock_id_instance),
patch.object(
MockIdentityClass, "from_bytes", return_value=mock_id_instance
MockIdentityClass,
"from_bytes",
return_value=mock_id_instance,
),
):
# Setup mock transport
@ -83,7 +89,7 @@ def test_reticulum_meshchat_init(mock_rns, temp_dir):
with (
patch("meshchatx.src.backend.identity_context.Database") as mock_db_class,
patch(
"meshchatx.src.backend.identity_context.ConfigManager"
"meshchatx.src.backend.identity_context.ConfigManager",
) as mock_config_class,
patch("meshchatx.src.backend.identity_context.MessageHandler"),
patch("meshchatx.src.backend.identity_context.AnnounceManager"),
@ -152,7 +158,7 @@ def test_reticulum_meshchat_init_with_auth(mock_rns, temp_dir):
with (
patch("meshchatx.src.backend.identity_context.Database"),
patch(
"meshchatx.src.backend.identity_context.ConfigManager"
"meshchatx.src.backend.identity_context.ConfigManager",
) as mock_config_class,
patch("meshchatx.src.backend.identity_context.MessageHandler"),
patch("meshchatx.src.backend.identity_context.AnnounceManager"),

View file

@ -1,11 +1,12 @@
import shutil
import tempfile
import base64
import secrets
from unittest.mock import MagicMock, patch, mock_open
import shutil
import tempfile
from unittest.mock import MagicMock, mock_open, patch
import pytest
import RNS
from meshchatx.meshchat import ReticulumMeshChat, main
@ -43,12 +44,16 @@ def mock_rns():
patch("LXMF.LXMRouter"),
patch.object(ReticulumMeshChat, "announce_loop", return_value=None),
patch.object(
ReticulumMeshChat, "announce_sync_propagation_nodes", return_value=None
ReticulumMeshChat,
"announce_sync_propagation_nodes",
return_value=None,
),
patch.object(ReticulumMeshChat, "crawler_loop", return_value=None),
patch.object(ReticulumMeshChat, "auto_backup_loop", return_value=None),
patch.object(
ReticulumMeshChat, "send_config_to_websocket_clients", return_value=None
ReticulumMeshChat,
"send_config_to_websocket_clients",
return_value=None,
),
):
mock_id_instance = MockIdentityClass()
@ -57,7 +62,9 @@ def mock_rns():
patch.object(MockIdentityClass, "from_file", return_value=mock_id_instance),
patch.object(MockIdentityClass, "recall", return_value=mock_id_instance),
patch.object(
MockIdentityClass, "from_bytes", return_value=mock_id_instance
MockIdentityClass,
"from_bytes",
return_value=mock_id_instance,
),
):
yield {
@ -73,7 +80,7 @@ def test_run_https_logic(mock_rns, temp_dir):
with (
patch("meshchatx.src.backend.identity_context.Database"),
patch(
"meshchatx.src.backend.identity_context.ConfigManager"
"meshchatx.src.backend.identity_context.ConfigManager",
) as mock_config_class,
patch("meshchatx.meshchat.generate_ssl_certificate") as mock_gen_cert,
patch("ssl.SSLContext") as mock_ssl_context,
@ -97,7 +104,7 @@ def test_run_https_logic(mock_rns, temp_dir):
mock_config = mock_config_class.return_value
# provide a real-looking secret key
mock_config.auth_session_secret.get.return_value = base64.urlsafe_b64encode(
secrets.token_bytes(32)
secrets.token_bytes(32),
).decode()
mock_config.display_name.get.return_value = "Test"
mock_config.lxmf_propagation_node_stamp_cost.get.return_value = 0
@ -137,7 +144,7 @@ def test_database_integrity_recovery(mock_rns, temp_dir):
with (
patch("meshchatx.src.backend.identity_context.Database") as mock_db_class,
patch(
"meshchatx.src.backend.identity_context.ConfigManager"
"meshchatx.src.backend.identity_context.ConfigManager",
) as mock_config_class,
patch("meshchatx.src.backend.identity_context.MessageHandler"),
patch("meshchatx.src.backend.identity_context.AnnounceManager"),
@ -190,7 +197,7 @@ def test_identity_loading_fallback(mock_rns, temp_dir):
with (
patch("meshchatx.src.backend.identity_context.Database"),
patch(
"meshchatx.src.backend.identity_context.ConfigManager"
"meshchatx.src.backend.identity_context.ConfigManager",
) as mock_config_class,
patch("RNS.Identity") as mock_id_class,
patch("os.path.exists", return_value=False), # Pretend files don't exist
@ -210,7 +217,7 @@ def test_identity_loading_fallback(mock_rns, temp_dir):
# Mock sys.argv to use default behavior (random generation)
with patch("sys.argv", ["meshchat.py", "--storage-dir", temp_dir]):
with patch(
"meshchatx.meshchat.ReticulumMeshChat"
"meshchatx.meshchat.ReticulumMeshChat",
): # Mock ReticulumMeshChat to avoid full init
with patch("aiohttp.web.run_app"):
main()
@ -235,25 +242,25 @@ def test_cli_flags_and_envs(mock_rns, temp_dir):
"MESHCHAT_AUTH": "1",
"MESHCHAT_STORAGE_DIR": temp_dir,
}
with patch.dict("os.environ", env):
with patch("sys.argv", ["meshchat.py"]):
main()
with patch.dict("os.environ", env), patch("sys.argv", ["meshchat.py"]):
main()
# Verify ReticulumMeshChat was called with values from ENV
args, kwargs = mock_app_class.call_args
assert kwargs["auto_recover"] is True
assert kwargs["auth_enabled"] is True
# Verify ReticulumMeshChat was called with values from ENV
args, kwargs = mock_app_class.call_args
assert kwargs["auto_recover"] is True
assert kwargs["auth_enabled"] is True
# Verify run was called with host/port from ENV
mock_app_instance = mock_app_class.return_value
run_args, run_kwargs = mock_app_instance.run.call_args
assert run_args[0] == "1.2.3.4"
assert run_args[1] == 9000
# Verify run was called with host/port from ENV
mock_app_instance = mock_app_class.return_value
run_args, run_kwargs = mock_app_instance.run.call_args
assert run_args[0] == "1.2.3.4"
assert run_args[1] == 9000
# Test CLI Flags (override Envs)
mock_app_class.reset_mock()
with patch.dict("os.environ", env):
with patch(
with (
patch.dict("os.environ", env),
patch(
"sys.argv",
[
"meshchat.py",
@ -265,11 +272,12 @@ def test_cli_flags_and_envs(mock_rns, temp_dir):
"--storage-dir",
temp_dir,
],
):
main()
),
):
main()
mock_app_instance = mock_app_class.return_value
run_args, run_kwargs = mock_app_instance.run.call_args
assert run_args[0] == "5.6.7.8"
assert run_args[1] == 7000
assert run_kwargs["enable_https"] is False
mock_app_instance = mock_app_class.return_value
run_args, run_kwargs = mock_app_instance.run.call_args
assert run_args[0] == "5.6.7.8"
assert run_args[1] == 7000
assert run_kwargs["enable_https"] is False

View file

@ -38,7 +38,9 @@ def temp_storage(tmp_path):
def test_telephone_manager_init(mock_identity, mock_config, temp_storage):
tm = TelephoneManager(
mock_identity, config_manager=mock_config, storage_dir=temp_storage
mock_identity,
config_manager=mock_config,
storage_dir=temp_storage,
)
assert tm.identity == mock_identity
assert tm.config_manager == mock_config
@ -48,7 +50,11 @@ def test_telephone_manager_init(mock_identity, mock_config, temp_storage):
@patch("meshchatx.src.backend.telephone_manager.Telephone")
def test_call_recording_lifecycle(
mock_telephone_class, mock_identity, mock_config, mock_db, temp_storage
mock_telephone_class,
mock_identity,
mock_config,
mock_db,
temp_storage,
):
# Setup mocks
mock_telephone = mock_telephone_class.return_value
@ -63,7 +69,10 @@ def test_call_recording_lifecycle(
mock_telephone.transmit_mixer = MagicMock()
tm = TelephoneManager(
mock_identity, config_manager=mock_config, storage_dir=temp_storage, db=mock_db
mock_identity,
config_manager=mock_config,
storage_dir=temp_storage,
db=mock_db,
)
tm.get_name_for_identity_hash = MagicMock(return_value="Remote User")
tm.init_telephone()
@ -90,7 +99,10 @@ def test_call_recording_lifecycle(
def test_call_recording_disabled(mock_identity, mock_config, mock_db, temp_storage):
mock_config.call_recording_enabled.get.return_value = False
tm = TelephoneManager(
mock_identity, config_manager=mock_config, storage_dir=temp_storage, db=mock_db
mock_identity,
config_manager=mock_config,
storage_dir=temp_storage,
db=mock_db,
)
# Mock telephone and active call
@ -105,13 +117,15 @@ def test_call_recording_disabled(mock_identity, mock_config, mock_db, temp_stora
def test_audio_profile_persistence(mock_identity, mock_config, temp_storage):
with patch(
"meshchatx.src.backend.telephone_manager.Telephone"
"meshchatx.src.backend.telephone_manager.Telephone",
) as mock_telephone_class:
mock_telephone = mock_telephone_class.return_value
mock_config.telephone_audio_profile_id.get.return_value = 4
tm = TelephoneManager(
mock_identity, config_manager=mock_config, storage_dir=temp_storage
mock_identity,
config_manager=mock_config,
storage_dir=temp_storage,
)
tm.init_telephone()
@ -121,7 +135,11 @@ def test_audio_profile_persistence(mock_identity, mock_config, temp_storage):
@patch("meshchatx.src.backend.telephone_manager.Telephone")
def test_call_recording_saves_after_disconnect(
mock_telephone_class, mock_identity, mock_config, mock_db, temp_storage
mock_telephone_class,
mock_identity,
mock_config,
mock_db,
temp_storage,
):
# Setup mocks
mock_telephone = mock_telephone_class.return_value
@ -136,7 +154,10 @@ def test_call_recording_saves_after_disconnect(
mock_telephone.transmit_mixer = MagicMock()
tm = TelephoneManager(
mock_identity, config_manager=mock_config, storage_dir=temp_storage, db=mock_db
mock_identity,
config_manager=mock_config,
storage_dir=temp_storage,
db=mock_db,
)
tm.init_telephone()
@ -162,11 +183,16 @@ def test_call_recording_saves_after_disconnect(
@patch("meshchatx.src.backend.telephone_manager.Telephone")
def test_manual_mute_overrides(
mock_telephone_class, mock_identity, mock_config, temp_storage
mock_telephone_class,
mock_identity,
mock_config,
temp_storage,
):
mock_telephone = mock_telephone_class.return_value
tm = TelephoneManager(
mock_identity, config_manager=mock_config, storage_dir=temp_storage
mock_identity,
config_manager=mock_config,
storage_dir=temp_storage,
)
tm.init_telephone()

View file

@ -1,5 +1,6 @@
import unittest
from unittest.mock import MagicMock, patch
from meshchatx.src.backend.translator_handler import TranslatorHandler

View file

@ -4,6 +4,7 @@ import tempfile
from unittest.mock import MagicMock, patch
import pytest
from meshchatx.src.backend.voicemail_manager import VoicemailManager

View file

@ -1,12 +1,13 @@
import socket
import unittest
from unittest.mock import MagicMock, patch
import socket
from meshchatx.src.backend.interfaces.WebsocketServerInterface import (
WebsocketServerInterface,
)
from meshchatx.src.backend.interfaces.WebsocketClientInterface import (
WebsocketClientInterface,
)
from meshchatx.src.backend.interfaces.WebsocketServerInterface import (
WebsocketServerInterface,
)
class TestWebsocketInterfaces(unittest.TestCase):
@ -43,7 +44,7 @@ class TestWebsocketInterfaces(unittest.TestCase):
# We don't want it to actually try connecting in this basic test
with patch(
"meshchatx.src.backend.interfaces.WebsocketClientInterface.threading.Thread"
"meshchatx.src.backend.interfaces.WebsocketClientInterface.threading.Thread",
):
client = WebsocketClientInterface(self.owner, config)
self.assertEqual(client.name, "test_ws_client")

View file

@ -182,13 +182,13 @@ describe("AboutPage.vue", () => {
});
mountAboutPage();
expect(axiosMock.get).toHaveBeenCalledTimes(4); // info, config, health, snapshots
expect(axiosMock.get).toHaveBeenCalledTimes(5); // info, config, health, snapshots, backups
vi.advanceTimersByTime(5000);
expect(axiosMock.get).toHaveBeenCalledTimes(5);
expect(axiosMock.get).toHaveBeenCalledTimes(6); // +1 from updateInterval
vi.advanceTimersByTime(5000);
expect(axiosMock.get).toHaveBeenCalledTimes(6);
expect(axiosMock.get).toHaveBeenCalledTimes(7); // +2 from updateInterval
});
it("handles vacuum database action", async () => {

Some files were not shown because too many files have changed in this diff Show more