diff --git a/meshchatx/src/backend/bot_handler.py b/meshchatx/src/backend/bot_handler.py index 47bcf2a..87ae310 100644 --- a/meshchatx/src/backend/bot_handler.py +++ b/meshchatx/src/backend/bot_handler.py @@ -125,6 +125,62 @@ class BotHandler: return None return BotHandler._normalize_lxmf_hash_hex(raw) + @staticmethod + def _read_bot_last_error(storage_dir): + if not storage_dir: + return None + path = os.path.join(storage_dir, "meshchatx_bot_last_error.txt") + try: + with open(path, encoding="utf-8") as f: + text = f.read().strip() + except OSError: + return None + if not text: + return None + max_len = 1600 + if len(text) > max_len: + return text[:max_len] + "\n..." + return text + + @staticmethod + def _subprocess_log_path(storage_dir): + if not storage_dir: + return None + return os.path.join(storage_dir, "meshchatx_bot_subprocess.log") + + def read_subprocess_log(self, bot_id, max_bytes=524_288): + 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}") + storage_dir = entry.get("storage_dir") + path = BotHandler._subprocess_log_path(storage_dir) + if not path: + return {"log": None, "truncated": False, "total_bytes": 0} + try: + total = os.path.getsize(path) + except OSError: + return {"log": None, "truncated": False, "total_bytes": 0} + if total == 0: + return {"log": "", "truncated": False, "total_bytes": 0} + truncated = total > max_bytes + to_read = min(total, max_bytes) + try: + with open(path, "rb") as f: + if truncated: + f.seek(total - to_read) + raw = f.read() + except OSError: + return {"log": None, "truncated": False, "total_bytes": total} + text = raw.decode("utf-8", errors="replace") + if truncated and "\n" in text: + _first, _sep, rest = text.partition("\n") + text = rest if rest else _first + return {"log": text, "truncated": truncated, "total_bytes": total} + def get_status(self): bots: list[dict] = [] @@ -180,6 +236,9 @@ class BotHandler: with contextlib.suppress(Exception): address_pretty = RNS.prettyhexrep(bytes.fromhex(address_full)) + storage_dir = entry.get("storage_dir") + last_err = self._read_bot_last_error(storage_dir) + bots.append( { "id": bot_id, @@ -191,7 +250,8 @@ class BotHandler: "full_address": address_full, "running": running, "pid": pid, - "storage_dir": entry.get("storage_dir"), + "storage_dir": storage_dir, + "last_error": last_err, }, ) @@ -237,6 +297,10 @@ class BotHandler: os.makedirs(bot_storage_dir, exist_ok=True) + err_file = os.path.join(bot_storage_dir, "meshchatx_bot_last_error.txt") + with contextlib.suppress(OSError): + os.unlink(err_file) + cmd = [ sys.executable, self.runner_path, @@ -252,7 +316,29 @@ class BotHandler: entry["reticulum_config_dir"], ] - proc = subprocess.Popen(cmd, cwd=bot_storage_dir) + subprocess_log = os.path.join(bot_storage_dir, "meshchatx_bot_subprocess.log") + log_f = open( + subprocess_log, + "a", + encoding="utf-8", + ) + try: + log_f.write(f"\n--- start {time.strftime('%Y-%m-%d %H:%M:%S')} ---\n") + log_f.flush() + proc = subprocess.Popen( + cmd, + cwd=bot_storage_dir, + stdout=log_f, + stderr=subprocess.STDOUT, + start_new_session=True, + env={**os.environ, "PYTHONUNBUFFERED": "1"}, + ) + except Exception: + log_f.close() + raise + else: + log_f.close() + entry["pid"] = proc.pid self._save_state() diff --git a/meshchatx/src/backend/bot_process.py b/meshchatx/src/backend/bot_process.py index fd3fc65..2414b32 100644 --- a/meshchatx/src/backend/bot_process.py +++ b/meshchatx/src/backend/bot_process.py @@ -5,6 +5,7 @@ import contextlib import os import threading import time +import traceback from meshchatx.src.backend.bot_templates import ( EchoBotTemplate, @@ -52,6 +53,11 @@ def main(): ) args = parser.parse_args() + storage_abs = os.path.abspath(args.storage) + err_path = os.path.join(storage_abs, "meshchatx_bot_last_error.txt") + with contextlib.suppress(OSError): + os.unlink(err_path) + os.makedirs(args.storage, exist_ok=True) config_path = args.config_path @@ -65,16 +71,22 @@ def main(): ) os.makedirs(reticulum_config_dir, exist_ok=True) - BotCls = TEMPLATE_MAP[args.template] - bot_instance = BotCls( - name=args.name, - storage_path=args.storage, - test_mode=False, - config_path=config_path, - reticulum_config_dir=reticulum_config_dir, - ) - - storage_abs = os.path.abspath(args.storage) + try: + BotCls = TEMPLATE_MAP[args.template] + bot_instance = BotCls( + name=args.name, + storage_path=args.storage, + test_mode=False, + config_path=config_path, + reticulum_config_dir=reticulum_config_dir, + ) + except BaseException: + try: + with open(err_path, "w", encoding="utf-8") as ef: + traceback.print_exc(file=ef) + except OSError: + pass + raise with contextlib.suppress(OSError): with open( os.path.join(config_path, "bot_display_name.txt"), @@ -112,7 +124,15 @@ def main(): elif hasattr(bot_instance.bot, "_announce"): bot_instance.bot._announce() - bot_instance.run() + try: + bot_instance.run() + except BaseException: + try: + with open(err_path, "w", encoding="utf-8") as ef: + traceback.print_exc(file=ef) + except OSError: + pass + raise if __name__ == "__main__": diff --git a/meshchatx/src/frontend/components/tools/BotsPage.vue b/meshchatx/src/frontend/components/tools/BotsPage.vue index 7c467af..f0f175a 100644 --- a/meshchatx/src/frontend/components/tools/BotsPage.vue +++ b/meshchatx/src/frontend/components/tools/BotsPage.vue @@ -132,6 +132,14 @@ > + + + - - {{ - $t("bots.lxmf_address") - }} - + + + {{ $t("bots.lxmf_address") }} + + + + {{ lxmfAddressFor(bot) }} + + {{ + $t("bots.address_pending") + }} + + + + + {{ $t("bots.last_announce") }} + + + {{ + formatRelativeSince(bot.last_announce_at) + }} + {{ + $t("bots.never_announced") + }} + — + + + + + + + {{ $t("bots.last_error_heading") }} + + {{ botLastError(bot) }} - {{ lxmfAddressFor(bot) }} - - {{ $t("bots.address_pending") }} - - {{ - $t("bots.last_announce") - }} - {{ - formatRelativeSince(bot.last_announce_at) - }} - {{ - $t("bots.never_announced") - }} - — - - + {{ bot.template_id || bot.template }} @@ -255,6 +291,67 @@ + + + + + + {{ $t("bots.process_log_title") }} + + + {{ processLogModalBot.name }} + + + {{ $t("bots.process_log_truncated") }} + + + + + + + + + + + + + + {{ $t("bots.process_log_loading") }} + + + {{ processLogDisplayText }} + + + + + diff --git a/tests/backend/test_bot_handler_extended.py b/tests/backend/test_bot_handler_extended.py index de27e25..72d1ce9 100644 --- a/tests/backend/test_bot_handler_extended.py +++ b/tests/backend/test_bot_handler_extended.py @@ -198,3 +198,41 @@ def test_request_announce_not_running(temp_identity_dir): ] with pytest.raises(RuntimeError, match="not running"): handler.request_announce(sid) + + +def test_get_status_subprocess_log_not_shown_as_last_error(temp_identity_dir): + handler = BotHandler(temp_identity_dir) + sid = "b1" + storage = os.path.join(handler.bots_dir, sid) + os.makedirs(storage, exist_ok=True) + log_path = os.path.join(storage, "meshchatx_bot_subprocess.log") + with open(log_path, "w", encoding="utf-8") as f: + f.write("[Info] Received SIGTERM, shutting down now!\n") + handler.bots_state = [ + {"id": sid, "template_id": "echo", "storage_dir": storage, "pid": None} + ] + status = handler.get_status() + assert status["bots"][0]["last_error"] is None + + +def test_read_subprocess_log(temp_identity_dir): + handler = BotHandler(temp_identity_dir) + sid = "b1" + storage = os.path.join(handler.bots_dir, sid) + os.makedirs(storage, exist_ok=True) + log_path = os.path.join(storage, "meshchatx_bot_subprocess.log") + with open(log_path, "w", encoding="utf-8") as f: + f.write("line1\nline2\n") + handler.bots_state = [ + {"id": sid, "template_id": "echo", "storage_dir": storage, "pid": None} + ] + out = handler.read_subprocess_log(sid) + assert out["truncated"] is False + assert out["total_bytes"] > 0 + assert "line2" in (out["log"] or "") + + +def test_read_subprocess_log_unknown_bot(temp_identity_dir): + handler = BotHandler(temp_identity_dir) + with pytest.raises(ValueError, match="Unknown bot"): + handler.read_subprocess_log("nope")
{{ botLastError(bot) }}
+ {{ processLogModalBot.name }} +
+ {{ $t("bots.process_log_truncated") }} +
{{ processLogDisplayText }}