diff --git a/meshchatx/src/backend/bot_handler.py b/meshchatx/src/backend/bot_handler.py index 4d49222..e9b0355 100644 --- a/meshchatx/src/backend/bot_handler.py +++ b/meshchatx/src/backend/bot_handler.py @@ -4,6 +4,7 @@ import contextlib import json import logging import os +import re import shutil import subprocess import sys @@ -14,6 +15,8 @@ import RNS logger = logging.getLogger("meshchatx.bots") +_LXMF_HASH_RE = re.compile(r"^[0-9a-f]{32}$") + class BotHandler: def __init__(self, identity_path, config_manager=None): @@ -98,13 +101,42 @@ class BotHandler: except Exception as exc: logger.warning("Failed to restore bot %s: %s", entry.get("id"), exc) + @staticmethod + def _normalize_lxmf_hash_hex(value): + if not value: + return None + if isinstance(value, memoryview): + value = value.tobytes() + if isinstance(value, bytes): + h = value.hex() + else: + h = str(value).strip().lower() + h = h.replace(" ", "").replace("<", "").replace(">", "") + if len(h) != 32 or not _LXMF_HASH_RE.match(h): + return None + return h + + @staticmethod + def _read_lxmf_address_sidecar(storage_dir): + if not storage_dir: + return None + path = os.path.join(storage_dir, "meshchatx_lxmf_address.txt") + try: + with open(path, encoding="utf-8") as f: + raw = f.read().strip() + except OSError: + return None + return BotHandler._normalize_lxmf_hash_hex(raw) + def get_status(self): bots: list[dict] = [] for entry in self.bots_state: bot_id = entry.get("id") template = entry.get("template_id") or entry.get("template") - name = entry.get("name") or "Unknown" + name = entry.get("name") + if not name: + name = f"{template.title()} Bot" if template else "Bot" pid = entry.get("pid") running = False @@ -124,8 +156,12 @@ class BotHandler: and getattr(instance.bot, "local", None) ): with contextlib.suppress(Exception): - address_pretty = RNS.prettyhexrep(instance.bot.local.hash) - address_full = RNS.hexrep(instance.bot.local.hash, delimit=False) + lh = instance.bot.local.hash + address_full = lh.hex() if isinstance(lh, (bytes, bytearray)) else None + if address_full: + address_full = self._normalize_lxmf_hash_hex(address_full) + if address_full: + address_pretty = RNS.prettyhexrep(bytes.fromhex(address_full)) # Fallback to identity file on disk if address_full is None: @@ -133,8 +169,15 @@ class BotHandler: if identity: with contextlib.suppress(Exception): destination = RNS.Destination(identity, "lxmf", "delivery") - address_full = destination.hash.hex() - address_pretty = RNS.prettyhexrep(destination.hash) + address_full = self._normalize_lxmf_hash_hex(destination.hash) + if address_full: + address_pretty = RNS.prettyhexrep(bytes.fromhex(address_full)) + + if address_full is None: + address_full = self._read_lxmf_address_sidecar(entry.get("storage_dir")) + if address_full: + with contextlib.suppress(Exception): + address_pretty = RNS.prettyhexrep(bytes.fromhex(address_full)) bots.append( { @@ -142,7 +185,8 @@ class BotHandler: "template": template, "template_id": template, "name": name, - "address": address_pretty or "Unknown", + "address": address_pretty, + "lxmf_address": address_full, "full_address": address_full, "running": running, "pid": pid, @@ -283,6 +327,57 @@ class BotHandler: storage_dir=entry["storage_dir"], ) + def update_bot_name(self, bot_id, name): + raw = (name or "").strip() + raw = re.sub(r"[\r\n]+", "", raw) + if not raw: + raise ValueError("name is required") + if len(raw) > 256: + raise ValueError("name too long") + 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}") + entry["name"] = raw + self._save_state() + sd = entry.get("storage_dir") + if sd: + try: + cfg_dir = entry.get("bot_config_dir") or os.path.join(sd, "config") + os.makedirs(cfg_dir, exist_ok=True) + path = os.path.join(cfg_dir, "bot_display_name.txt") + with open(path, "w", encoding="utf-8") as f: + f.write(raw) + except OSError as exc: + logger.warning("Failed to write bot display name file: %s", exc) + return True + + def request_announce(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}") + pid = entry.get("pid") + if not pid or not self._is_pid_alive(pid): + raise RuntimeError("bot is not running") + sd = entry.get("storage_dir") + if not sd: + raise RuntimeError("bot has no storage directory") + req = os.path.join(sd, "meshchatx_request_announce") + try: + with open(req, "w", encoding="utf-8") as f: + f.write("1") + except OSError as exc: + logger.warning("Failed to write announce request: %s", exc) + raise RuntimeError("failed to write announce request") from exc + return True + def delete_bot(self, bot_id): # Stop it first self.stop_bot(bot_id) @@ -332,6 +427,9 @@ class BotHandler: id_path_bot_cfg = os.path.join(bot_config_dir, "identity") if os.path.exists(id_path_bot_cfg): return id_path_bot_cfg + id_path_lxmf_cfg = os.path.join(bot_config_dir, "lxmf", "identity") + if os.path.exists(id_path_lxmf_cfg): + return id_path_lxmf_cfg reticulum_config_dir = entry.get("reticulum_config_dir") if reticulum_config_dir: @@ -354,6 +452,10 @@ class BotHandler: if os.path.exists(id_path_lxmf): return id_path_lxmf + id_path_lxmf_root = os.path.join(storage_dir, "lxmf", "identity") + if os.path.exists(id_path_lxmf_root): + return id_path_lxmf_root + return None def _load_identity_for_bot(self, bot_id): diff --git a/meshchatx/src/backend/bot_process.py b/meshchatx/src/backend/bot_process.py index 0b71478..fd3fc65 100644 --- a/meshchatx/src/backend/bot_process.py +++ b/meshchatx/src/backend/bot_process.py @@ -3,6 +3,8 @@ import argparse import contextlib import os +import threading +import time from meshchatx.src.backend.bot_templates import ( EchoBotTemplate, @@ -17,6 +19,24 @@ TEMPLATE_MAP = { } +def _control_watcher(bot_instance, storage_dir): + """MeshChatX trigger file for on-demand announces (LXMFy reads bot_display_name.txt in config).""" + announce_req = os.path.join(storage_dir, "meshchatx_request_announce") + while True: + time.sleep(0.6) + try: + if os.path.isfile(announce_req): + os.unlink(announce_req) + if hasattr(bot_instance.bot, "announce_now"): + bot_instance.bot.announce_now(force=True) + elif hasattr(bot_instance.bot, "_announce"): + bot_instance.bot._announce() + except OSError: + pass + except Exception: + pass + + def main(): parser = argparse.ArgumentParser() parser.add_argument("--template", required=True, choices=TEMPLATE_MAP.keys()) @@ -54,11 +74,42 @@ def main(): reticulum_config_dir=reticulum_config_dir, ) + storage_abs = os.path.abspath(args.storage) + with contextlib.suppress(OSError): + with open( + os.path.join(config_path, "bot_display_name.txt"), + "w", + encoding="utf-8", + ) as f: + f.write(args.name.strip()) + + watcher = threading.Thread( + target=_control_watcher, + args=(bot_instance, storage_abs), + daemon=True, + name="meshchatx-bot-control", + ) + watcher.start() + + with contextlib.suppress(Exception): + local = getattr(bot_instance.bot, "local", None) + if local is not None: + raw = getattr(local, "hash", None) + if raw is not None: + hx = raw.hex() if isinstance(raw, (bytes, bytearray)) else str(raw) + hx = hx.strip().lower() + if len(hx) == 32: + sidecar = os.path.join(storage_abs, "meshchatx_lxmf_address.txt") + with open(sidecar, "w", encoding="utf-8") as f: + f.write(hx) + # Optional immediate announce for reachability with contextlib.suppress(Exception): if hasattr(bot_instance.bot, "announce_enabled"): bot_instance.bot.announce_enabled = True - if hasattr(bot_instance.bot, "_announce"): + if hasattr(bot_instance.bot, "announce_now"): + bot_instance.bot.announce_now(force=True) + elif hasattr(bot_instance.bot, "_announce"): bot_instance.bot._announce() bot_instance.run() diff --git a/meshchatx/src/frontend/components/tools/BotsPage.vue b/meshchatx/src/frontend/components/tools/BotsPage.vue index 1def9d0..2df7170 100644 --- a/meshchatx/src/frontend/components/tools/BotsPage.vue +++ b/meshchatx/src/frontend/components/tools/BotsPage.vue @@ -17,7 +17,6 @@
-

{{ $t("bots.create_new_bot") }} @@ -26,7 +25,7 @@
@@ -35,13 +34,11 @@ {{ template.description }}
- + +

-

{{ $t("bots.saved_bots") }} @@ -72,8 +68,79 @@
+
+ + + + + + + +
+
-
-
- {{ bot.name }} +
+
+ +
-
- {{ bot.address || runningMap[bot.id]?.address || "Not running" }} +
+ + {{ + bot.running ? $t("bots.status_running") : $t("bots.status_stopped") + }} +
+
+ {{ + $t("bots.lxmf_address") + }} + + {{ $t("bots.address_pending") }} +
+
+ {{ + $t("bots.last_announce") + }} + {{ + formatRelativeSince(bot.last_announce_at) + }} + {{ + $t("bots.never_announced") + }} +
{{ bot.template_id || bot.template }}
-
- {{ bot.storage_dir }} -
-
- - - - -
@@ -142,7 +240,6 @@

-
-
+
@@ -218,38 +316,37 @@ export default { return { bots: [], templates: [], - runningBots: [], selectedTemplate: null, newBotName: "", isStarting: false, loading: true, refreshInterval: null, + relativeTimerTick: 0, + relativeTimerInterval: null, + editingBotId: null, + editingNameDraft: "", }; }, - computed: { - runningMap() { - const map = {}; - this.runningBots.forEach((b) => { - map[b.id] = b; - }); - return map; - }, - }, mounted() { this.getStatus(); this.refreshInterval = setInterval(this.getStatus, 5000); + this.relativeTimerInterval = setInterval(() => { + this.relativeTimerTick += 1; + }, 1000); }, beforeUnmount() { if (this.refreshInterval) { clearInterval(this.refreshInterval); } + if (this.relativeTimerInterval) { + clearInterval(this.relativeTimerInterval); + } }, methods: { async getStatus() { try { const response = await window.api.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) { @@ -317,6 +414,9 @@ export default { try { await window.api.post("/api/v1/bots/delete", { bot_id: botId }); ToastUtils.success(this.$t("bots.bot_deleted")); + if (this.editingBotId === botId) { + this.cancelEditName(); + } this.getStatus(); } catch (e) { console.error(e); @@ -326,10 +426,110 @@ export default { exportIdentity(botId) { window.open(`/api/v1/bots/export?bot_id=${botId}`, "_blank"); }, - copyToClipboard(text) { - navigator.clipboard.writeText(text); + startEditName(bot) { + this.editingBotId = bot.id; + this.editingNameDraft = bot.name || ""; + }, + cancelEditName() { + this.editingBotId = null; + this.editingNameDraft = ""; + }, + async saveBotName(bot) { + if (this.editingBotId !== bot.id) { + return; + } + const name = (this.editingNameDraft || "").trim(); + if (!name) { + ToastUtils.error(this.$t("bots.name_required")); + return; + } + try { + await window.api.patch("/api/v1/bots/update", { + bot_id: bot.id, + name, + }); + ToastUtils.success(this.$t("bots.bot_renamed")); + this.cancelEditName(); + this.getStatus(); + } catch (e) { + console.error(e); + ToastUtils.error(e.response?.data?.message || this.$t("bots.rename_failed")); + } + }, + async forceAnnounce(bot) { + try { + await window.api.post("/api/v1/bots/announce", { bot_id: bot.id }); + ToastUtils.success(this.$t("bots.announce_triggered")); + this.getStatus(); + } catch (e) { + console.error(e); + ToastUtils.error(e.response?.data?.message || this.$t("bots.announce_failed")); + } + }, + lxmfAddressFor(bot) { + const raw = bot.lxmf_address || bot.full_address; + if (!raw || typeof raw !== "string") { + return ""; + } + const h = raw.trim().toLowerCase(); + return h.length === 32 && /^[0-9a-f]+$/.test(h) ? h : ""; + }, + openChatWithBot(bot) { + const h = this.lxmfAddressFor(bot); + if (!h) { + return; + } + const routeName = this.$route?.meta?.isPopout ? "messages-popout" : "messages"; + this.$router.push({ name: routeName, params: { destinationHash: h } }); + }, + copyLxmfAddress(bot) { + const h = this.lxmfAddressFor(bot); + if (!h) { + return; + } + navigator.clipboard.writeText(h); ToastUtils.success(this.$t("translator.copied_to_clipboard")); }, + formatRelativeSince(iso) { + if (!iso) { + return ""; + } + let t; + try { + t = new Date(iso).getTime(); + } catch { + return String(iso); + } + if (Number.isNaN(t)) { + return String(iso); + } + const now = Date.now() + 0 * this.relativeTimerTick; + let sec = Math.floor((now - t) / 1000); + if (sec < 0) { + sec = 0; + } + if (sec < 60) { + return `${sec}s`; + } + const min = Math.floor(sec / 60); + if (min < 60) { + return min === 1 ? `${min}m` : `${min}m`; + } + const h = Math.floor(min / 60); + if (h < 24) { + return h === 1 ? `${h}h` : `${h}h`; + } + const d = Math.floor(h / 24); + if (d < 30) { + return d === 1 ? "1 day" : `${d} days`; + } + const mo = Math.floor(d / 30); + if (d < 365) { + return mo === 1 ? "1 month" : `${mo} months`; + } + const y = Math.floor(d / 365); + return y === 1 ? "1 year" : `${y} years`; + }, }, }; diff --git a/tests/backend/test_bot_handler_extended.py b/tests/backend/test_bot_handler_extended.py index 6304824..09fafc5 100644 --- a/tests/backend/test_bot_handler_extended.py +++ b/tests/backend/test_bot_handler_extended.py @@ -52,24 +52,6 @@ def test_delete_bot_not_found(temp_identity_dir): assert handler.delete_bot("nonexistent") is False -@patch("subprocess.Popen") -def test_start_stop_bot(mock_popen, temp_identity_dir): - mock_process = MagicMock() - mock_process.pid = 12345 - mock_popen.return_value = mock_process - - handler = BotHandler(temp_identity_dir) - bot_id = handler.start_bot("echo", "My Echo Bot") - - assert bot_id in handler.running_bots - status = handler.get_status() - assert any(b["id"] == bot_id and b["running"] for b in status["bots"]) - - with patch("psutil.Process"): - handler.stop_bot(bot_id) - assert bot_id not in handler.running_bots - - def test_create_bot(temp_identity_dir): handler = BotHandler(temp_identity_dir) # start_bot acts as create_bot if bot_id is None @@ -111,3 +93,102 @@ def test_restore_enabled_bots(temp_identity_dir): with patch.object(handler, "start_bot") as mock_start: handler.restore_enabled_bots() mock_start.assert_called_once() + + +def test_get_status_default_name_from_template(temp_identity_dir): + handler = BotHandler(temp_identity_dir) + sid = "b1" + storage = os.path.join(handler.bots_dir, sid) + os.makedirs(storage, exist_ok=True) + handler.bots_state = [{"id": sid, "template_id": "echo", "storage_dir": storage}] + status = handler.get_status() + assert status["bots"][0]["name"] == "Echo Bot" + + +def test_get_status_reads_sidecar_lxmf_address(temp_identity_dir): + handler = BotHandler(temp_identity_dir) + sid = "b1" + storage = os.path.join(handler.bots_dir, sid) + os.makedirs(storage, exist_ok=True) + hx = "a" * 32 + with open(os.path.join(storage, "meshchatx_lxmf_address.txt"), "w", encoding="utf-8") as f: + f.write(hx) + handler.bots_state = [{"id": sid, "template_id": "echo", "storage_dir": storage}] + status = handler.get_status() + assert status["bots"][0]["lxmf_address"] == hx + assert status["bots"][0]["full_address"] == hx + assert status["bots"][0]["address"] is not None + + +@patch("subprocess.Popen") +def test_start_stop_bot(mock_popen, temp_identity_dir): + mock_process = MagicMock() + mock_process.pid = 12345 + mock_popen.return_value = mock_process + + handler = BotHandler(temp_identity_dir) + bot_id = handler.start_bot("echo", "My Echo Bot") + + assert bot_id in handler.running_bots + status = handler.get_status() + assert any(b["id"] == bot_id and b["running"] for b in status["bots"]) + + with patch("meshchatx.src.backend.bot_handler.os.kill") as mock_kill: + handler.stop_bot(bot_id) + assert mock_kill.called + assert bot_id not in handler.running_bots + + +def test_update_bot_name_writes_sidecar(temp_identity_dir): + handler = BotHandler(temp_identity_dir) + sid = "b1" + storage = os.path.join(handler.bots_dir, sid) + cfg = os.path.join(storage, "config") + os.makedirs(cfg, exist_ok=True) + handler.bots_state = [ + { + "id": sid, + "template_id": "echo", + "name": "Old", + "storage_dir": storage, + "bot_config_dir": cfg, + }, + ] + handler.update_bot_name(sid, "New Name") + assert handler.bots_state[0]["name"] == "New Name" + with open(os.path.join(cfg, "bot_display_name.txt"), encoding="utf-8") as f: + assert f.read() == "New Name" + + +def test_update_bot_name_rejects_empty(temp_identity_dir): + handler = BotHandler(temp_identity_dir) + sid = "b1" + storage = os.path.join(handler.bots_dir, sid) + os.makedirs(storage, exist_ok=True) + handler.bots_state = [{"id": sid, "template_id": "echo", "storage_dir": storage}] + with pytest.raises(ValueError, match="name is required"): + handler.update_bot_name(sid, " ") + + +@patch.object(BotHandler, "_is_pid_alive", return_value=True) +def test_request_announce_writes_trigger(mock_alive, temp_identity_dir): + handler = BotHandler(temp_identity_dir) + sid = "b1" + storage = os.path.join(handler.bots_dir, sid) + os.makedirs(storage, exist_ok=True) + handler.bots_state = [{"id": sid, "template_id": "echo", "storage_dir": storage, "pid": 99999}] + handler.request_announce(sid) + req = os.path.join(storage, "meshchatx_request_announce") + assert os.path.isfile(req) + with open(req, encoding="utf-8") as f: + assert f.read() == "1" + + +def test_request_announce_not_running(temp_identity_dir): + handler = BotHandler(temp_identity_dir) + sid = "b1" + storage = os.path.join(handler.bots_dir, sid) + os.makedirs(storage, exist_ok=True) + handler.bots_state = [{"id": sid, "template_id": "echo", "storage_dir": storage, "pid": None}] + with pytest.raises(RuntimeError, match="not running"): + handler.request_announce(sid) diff --git a/tests/frontend/BotsPage.test.js b/tests/frontend/BotsPage.test.js index cdfb106..16cfb7f 100644 --- a/tests/frontend/BotsPage.test.js +++ b/tests/frontend/BotsPage.test.js @@ -4,11 +4,14 @@ import BotsPage from "@/components/tools/BotsPage.vue"; describe("BotsPage.vue", () => { let axiosMock; + let routerPush; beforeEach(() => { + routerPush = vi.fn(); axiosMock = { get: vi.fn(), post: vi.fn(), + patch: vi.fn(), }; window.api = axiosMock; @@ -17,8 +20,16 @@ describe("BotsPage.vue", () => { return Promise.resolve({ data: { status: { - bots: [{ id: "bot1", name: "Test Bot", address: "addr1", template_id: "echo" }], - running_bots: [{ id: "bot1", address: "addr1" }], + bots: [ + { + id: "bot1", + name: "Test Bot", + address: "", + lxmf_address: "a".repeat(32), + running: true, + template_id: "echo", + }, + ], }, templates: [{ id: "echo", name: "Echo Bot", description: "Echos messages" }], }, @@ -38,6 +49,8 @@ describe("BotsPage.vue", () => { global: { mocks: { $t: (key) => key, + $router: { push: routerPush }, + $route: { meta: {} }, }, stubs: { MaterialDesignIcon: { @@ -62,8 +75,11 @@ describe("BotsPage.vue", () => { const wrapper = mountBotsPage(); await vi.waitFor(() => expect(wrapper.vm.loading).toBe(false)); - const selectBtn = wrapper.findAll("button").find((b) => b.text().includes("bots.select")); - await selectBtn.trigger("click"); + const cards = wrapper.findAll("div.cursor-pointer"); + const templateCard = cards.filter( + (d) => d.text().includes("Echo Bot") && d.text().includes("Echos messages"), + )[0]; + await templateCard.trigger("click"); expect(wrapper.vm.selectedTemplate).not.toBeNull(); expect(wrapper.text()).toContain("bots.start_bot: Echo Bot"); @@ -78,8 +94,7 @@ describe("BotsPage.vue", () => { newBotName: "My New Bot", }); - const startButton = wrapper.findAll("button").find((b) => b.text().includes("bots.start_bot")); - await startButton.trigger("click"); + await wrapper.vm.startBot(); expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/bots/start", { template_id: "echo", @@ -98,4 +113,17 @@ describe("BotsPage.vue", () => { bot_id: "bot1", }); }); + + it("navigates to messages when chat is clicked", async () => { + const wrapper = mountBotsPage(); + await vi.waitFor(() => expect(wrapper.vm.loading).toBe(false)); + + const chatButton = wrapper.find("button[title='bots.chat_with_bot']"); + await chatButton.trigger("click"); + + expect(routerPush).toHaveBeenCalledWith({ + name: "messages", + params: { destinationHash: "a".repeat(32) }, + }); + }); });