mirror of
https://git.quad4.io/RNS-Things/MeshChatX.git
synced 2026-04-25 15:22:10 +00:00
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:
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user