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.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) },
+ });
+ });
});