feat(bot_handler): update bot management with LXMF address handling and bot name updates

- Introduced methods for normalizing LXMF hashes and reading LXMF addresses from sidecar files.
- Updated bot status retrieval to include LXMF addresses and improved fallback mechanisms for bot names.
- Added functionality to update bot names and write changes to a sidecar file.
- Implemented a request mechanism for on-demand bot announcements.
- Enhanced tests to cover new features and ensure proper functionality.
This commit is contained in:
Ivan
2026-04-16 21:34:28 -05:00
parent bf967cdca9
commit 5d87aa3be2
5 changed files with 571 additions and 109 deletions

View File

@@ -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):

View File

@@ -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()

View File

@@ -17,7 +17,6 @@
</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") }}
@@ -26,7 +25,7 @@
<div
v-for="template in templates"
:key="template.id"
class="rounded-lg border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 p-4 hover:border-blue-400 dark:hover:border-blue-600 transition cursor-pointer flex flex-col justify-between min-h-[140px]"
class="relative rounded-lg border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 p-4 hover:border-blue-400 dark:hover:border-blue-600 transition cursor-pointer flex flex-col justify-between min-h-[140px] pr-12"
@click="selectTemplate(template)"
>
<div class="min-w-0">
@@ -35,13 +34,11 @@
{{ template.description }}
</div>
</div>
<button
type="button"
class="primary-chip w-full mt-4 py-2"
@click.stop="selectTemplate(template)"
<div
class="absolute bottom-3 right-3 p-2 rounded-lg text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100/80 dark:hover:bg-zinc-800/80 transition-colors pointer-events-none"
>
{{ $t("bots.select") }}
</button>
<MaterialDesignIcon icon-name="chevron-right" class="size-6" />
</div>
</div>
<div
@@ -60,7 +57,6 @@
</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") }}
@@ -72,8 +68,79 @@
<div
v-for="bot in bots"
:key="bot.id"
class="rounded-lg border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 p-3 sm:p-4 flex flex-col sm:flex-row sm:items-center gap-3 sm:justify-between"
:class="[
'relative rounded-lg border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 p-3 sm:p-4',
editingBotId === bot.id
? 'pr-28 sm:pr-40'
: 'pr-10 sm:pr-12',
]"
>
<div
class="absolute top-2 right-2 flex flex-wrap items-center justify-end gap-0.5 z-10 max-w-[min(100%,calc(100%-2rem))]"
>
<button
v-if="lxmfAddressFor(bot)"
type="button"
class="p-2 rounded-lg text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-gray-100/80 dark:hover:bg-zinc-800/80 transition-colors"
:title="$t('bots.chat_with_bot')"
@click="openChatWithBot(bot)"
>
<MaterialDesignIcon icon-name="message-text" class="size-5" />
</button>
<button
v-if="bot.running"
type="button"
class="p-2 rounded-lg text-gray-500 dark:text-gray-400 hover:text-amber-600 dark:hover:text-amber-400 hover:bg-gray-100/80 dark:hover:bg-zinc-800/80 transition-colors"
:title="$t('bots.force_announce')"
@click="forceAnnounce(bot)"
>
<MaterialDesignIcon icon-name="bullhorn" class="size-5" />
</button>
<button
v-if="bot.running"
type="button"
class="p-2 rounded-lg text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-gray-100/80 dark:hover:bg-zinc-800/80 transition-colors"
:title="$t('bots.restart_bot')"
@click="restartExisting(bot)"
>
<MaterialDesignIcon icon-name="refresh" class="size-5" />
</button>
<button
v-if="bot.running"
type="button"
class="p-2 rounded-lg text-gray-500 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-gray-100/80 dark:hover:bg-zinc-800/80 transition-colors"
:title="$t('bots.stop_bot')"
@click="stopBot(bot.id)"
>
<MaterialDesignIcon icon-name="stop" class="size-5" />
</button>
<button
v-if="!bot.running"
type="button"
class="p-2 rounded-lg text-gray-500 dark:text-gray-400 hover:text-emerald-600 dark:hover:text-emerald-400 hover:bg-gray-100/80 dark:hover:bg-zinc-800/80 transition-colors"
:title="$t('bots.start_bot')"
@click="startExisting(bot)"
>
<MaterialDesignIcon icon-name="play" class="size-5" />
</button>
<button
type="button"
class="p-2 rounded-lg text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100/80 dark:hover:bg-zinc-800/80 transition-colors"
:title="$t('bots.export_identity')"
@click="exportIdentity(bot.id)"
>
<MaterialDesignIcon icon-name="export" class="size-5" />
</button>
<button
type="button"
class="p-2 rounded-lg text-gray-500 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-gray-100/80 dark:hover:bg-zinc-800/80 transition-colors"
:title="$t('bots.delete_bot')"
@click="deleteBot(bot.id)"
>
<MaterialDesignIcon icon-name="delete" class="size-5" />
</button>
</div>
<div class="flex items-start gap-3 min-w-0">
<div class="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg shrink-0">
<MaterialDesignIcon
@@ -81,60 +148,91 @@
class="size-6 text-blue-600 dark:text-blue-400"
/>
</div>
<div class="min-w-0">
<div class="font-bold text-gray-900 dark:text-white truncate">
{{ bot.name }}
<div class="min-w-0 flex-1 space-y-1.5 sm:pr-2">
<div
class="flex items-center gap-1 min-w-0"
:class="editingBotId === bot.id ? 'max-w-[min(100%,14rem)] sm:max-w-[16rem]' : ''"
>
<template v-if="editingBotId === bot.id">
<input
v-model="editingNameDraft"
type="text"
class="input-field text-xs py-1 h-8 px-2 min-w-0 flex-1 max-w-[10rem] sm:max-w-[12rem]"
maxlength="256"
@keydown.enter.prevent="saveBotName(bot)"
@keydown.escape="cancelEditName"
/>
<button
type="button"
class="p-1 rounded-lg text-gray-500 hover:text-emerald-600 hover:bg-gray-100/80 dark:hover:bg-zinc-800/80 shrink-0"
:title="$t('common.save')"
@click="saveBotName(bot)"
>
<MaterialDesignIcon icon-name="check" class="size-4" />
</button>
<button
type="button"
class="p-1 rounded-lg text-gray-500 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100/80 dark:hover:bg-zinc-800/80 shrink-0"
:title="$t('common.cancel')"
@click="cancelEditName"
>
<MaterialDesignIcon icon-name="close" class="size-4" />
</button>
</template>
<template v-else>
<span class="font-bold text-gray-900 dark:text-white truncate">{{
bot.name
}}</span>
<button
type="button"
class="p-1.5 rounded-lg text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100/80 dark:hover:bg-zinc-800/80 shrink-0"
:title="$t('bots.edit_name')"
@click="startEditName(bot)"
>
<MaterialDesignIcon icon-name="pencil" class="size-4" />
</button>
</template>
</div>
<div class="text-xs font-mono text-gray-500 break-all">
{{ bot.address || runningMap[bot.id]?.address || "Not running" }}
<div class="flex items-center gap-2 text-[11px] text-gray-600 dark:text-gray-300">
<span
class="inline-block size-2 rounded-full shrink-0"
:class="bot.running ? 'bg-emerald-500' : 'bg-gray-400 dark:bg-gray-500'"
></span>
<span>{{
bot.running ? $t("bots.status_running") : $t("bots.status_stopped")
}}</span>
</div>
<div class="text-[11px] text-gray-500 dark:text-gray-400">
<span class="font-semibold text-gray-600 dark:text-gray-300">{{
$t("bots.lxmf_address")
}}</span>
<button
v-if="lxmfAddressFor(bot)"
type="button"
class="font-mono break-all text-left text-gray-800 dark:text-gray-200 hover:underline"
@click="copyLxmfAddress(bot)"
>
{{ lxmfAddressFor(bot) }}
</button>
<span v-else>{{ $t("bots.address_pending") }}</span>
</div>
<div class="text-[11px] text-gray-500 dark:text-gray-400">
<span class="font-semibold text-gray-600 dark:text-gray-300">{{
$t("bots.last_announce")
}}</span>
<span v-if="bot.last_announce_at" class="ml-1.5">{{
formatRelativeSince(bot.last_announce_at)
}}</span>
<span v-else-if="lxmfAddressFor(bot)" class="ml-1.5">{{
$t("bots.never_announced")
}}</span>
<span v-else class="ml-1.5"></span>
</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 break-all">
{{ bot.storage_dir }}
</div>
</div>
</div>
<div
class="flex flex-wrap items-center gap-1.5 sm:gap-2 justify-end sm:shrink-0 pt-1 sm:pt-0 border-t border-gray-100 dark:border-zinc-800 sm:border-0"
>
<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>
@@ -142,7 +240,6 @@
</div>
</div>
<!-- Start Bot Modal -->
<div
v-if="selectedTemplate"
class="fixed inset-0 z-[100] flex items-end sm:items-center justify-center p-0 sm:p-4 bg-black/50"
@@ -156,7 +253,8 @@
{{ $t("bots.start_bot") }}: {{ selectedTemplate.name }}
</h3>
<button
class="p-1 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-lg"
type="button"
class="p-2 rounded-lg text-gray-500 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100/80 dark:hover:bg-zinc-800/80"
@click="selectedTemplate = null"
>
<MaterialDesignIcon icon-name="close" class="size-5" />
@@ -178,25 +276,25 @@
{{ selectedTemplate.description }}
</div>
<div class="flex flex-col-reverse sm:flex-row gap-2 sm:gap-3 sm:justify-end pt-2">
<div class="flex justify-end gap-2 pt-2">
<button
type="button"
class="secondary-chip px-6 py-2 w-full sm:w-auto"
class="p-2 rounded-lg text-gray-500 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100/80 dark:hover:bg-zinc-800/80"
@click="selectedTemplate = null"
>
{{ $t("bots.cancel") }}
<MaterialDesignIcon icon-name="close" class="size-6" />
</button>
<button
type="button"
class="primary-chip px-6 py-2 w-full sm:w-auto"
class="p-2 rounded-lg text-gray-500 hover:text-emerald-600 hover:bg-gray-100/80 dark:hover:bg-zinc-800/80 disabled:opacity-40"
: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"
class="inline-block w-5 h-5 border-2 border-current border-t-transparent rounded-full animate-spin"
></span>
{{ $t("bots.start_bot") }}
<MaterialDesignIcon v-else icon-name="check" class="size-6" />
</button>
</div>
</div>
@@ -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`;
},
},
};
</script>

View File

@@ -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)

View File

@@ -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: "<addr1>",
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) },
});
});
});