fix(bot,ui): bot self-detection, message display and browser reconnect

- Use BOT_NAME for self-reply detection instead of literal 'BOT'
- Suppress device name in bot message display to avoid duplication
- Add logging filter for NiceGUI deleted-client warning
- Reset local UI state on render() so browser reconnect repopulates
  device info, contacts and channels from SharedData without BLE refetch
This commit is contained in:
pe1hvh
2026-02-05 14:07:22 +01:00
parent 661f565f34
commit 35f46651dd
7 changed files with 228 additions and 12 deletions

1
data/nodes.json Normal file

File diff suppressed because one or more lines are too long

BIN
meshcore-gui.zip Normal file

Binary file not shown.

View File

@@ -32,12 +32,16 @@ no sanity-margin heuristics.
import asyncio
import threading
import time
from datetime import datetime
from typing import Dict, List, Optional, Set
from meshcore import MeshCore, EventType
from meshcore_gui.config import CHANNELS_CONFIG, debug_print
from meshcore_gui.config import (
BOT_CHANNEL, BOT_COOLDOWN_SECONDS, BOT_KEYWORDS, BOT_NAME,
CHANNELS_CONFIG, debug_print,
)
from meshcore_gui.packet_parser import PacketDecoder, PayloadType
from meshcore_gui.protocols import SharedDataWriter
@@ -68,6 +72,9 @@ class BLEWorker:
# Packet decoder (channel keys loaded at startup)
self._decoder = PacketDecoder()
# BOT: timestamp of last reply (cooldown enforcement)
self._bot_last_reply: float = 0.0
# Deduplication: message_hash values already processed via
# RX_LOG_DATA decode. When CHANNEL_MSG_RECV arrives for the
# same packet, it is silently dropped.
@@ -287,18 +294,25 @@ class BLEWorker:
if action == 'send_message':
channel = cmd.get('channel', 0)
text = cmd.get('text', '')
is_bot = cmd.get('_bot', False)
if text and self.mc:
await self.mc.commands.send_chan_msg(channel, text)
self.shared.add_message({
'time': datetime.now().strftime('%H:%M:%S'),
'sender': 'Me',
'text': text,
'channel': channel,
'direction': 'out',
'sender_pubkey': '',
'path_hashes': [],
})
debug_print(f"Sent message to channel {channel}: {text[:30]}")
# Bot replies appear via the radio echo (RX_LOG),
# so only add manual messages to the message list.
if not is_bot:
self.shared.add_message({
'time': datetime.now().strftime('%H:%M:%S'),
'sender': 'Me',
'text': text,
'channel': channel,
'direction': 'out',
'sender_pubkey': '',
'path_hashes': [],
})
debug_print(
f"{'BOT' if is_bot else 'Sent'} message to "
f"channel {channel}: {text[:30]}"
)
elif action == 'send_advert':
if self.mc:
@@ -328,6 +342,107 @@ class BLEWorker:
debug_print("Refresh requested")
await self._load_data()
# ------------------------------------------------------------------
# BOT — keyword-triggered auto-reply
# ------------------------------------------------------------------
def _bot_check_and_queue(
self,
sender: str,
text: str,
channel_idx,
snr,
path_len: int,
path_hashes: Optional[List[str]] = None,
) -> None:
"""Queue a BOT reply if all guards pass.
Guards:
1. BOT is enabled (checkbox in GUI)
2. Message is on the configured BOT_CHANNEL
3. Sender is not the BOT itself (prevent self-reply)
4. Sender name does not end with 'Bot' (prevent bot-to-bot loops)
5. Cooldown period has elapsed
6. Message text contains a recognised keyword
"""
# Guard 1: BOT enabled?
if not self.shared.is_bot_enabled():
return
# Guard 2: correct channel?
if channel_idx != BOT_CHANNEL:
return
# Guard 3: skip own messages (use BOT_NAME as identifier, not device name)
if sender == 'Me' or (text and text.startswith(BOT_NAME)):
return
# Guard 4: skip other bots (name ends with "Bot")
if sender and sender.rstrip().lower().endswith('bot'):
debug_print(f"BOT: skipping message from other bot '{sender}'")
return
# Guard 5: cooldown
now = time.time()
if now - self._bot_last_reply < BOT_COOLDOWN_SECONDS:
debug_print("BOT: cooldown active, skipping")
return
# Guard 6: keyword match (case-insensitive, first match wins)
text_lower = (text or '').lower()
matched_template = None
for keyword, template in BOT_KEYWORDS.items():
if keyword in text_lower:
matched_template = template
break
if matched_template is None:
return
# Build path string: "path(N); A>B" or "path(0)"
path_str = self._format_path(path_len, path_hashes)
# Build reply
snr_str = f"{snr:.1f}" if snr is not None else "?"
reply = matched_template.format(
bot=BOT_NAME,
sender=sender or "?",
snr=snr_str,
path=path_str,
)
# Update cooldown timestamp
self._bot_last_reply = now
# Queue as internal command — picked up by _process_commands
self.shared.put_command({
'action': 'send_message',
'channel': BOT_CHANNEL,
'text': reply,
'_bot': True,
})
debug_print(f"BOT: queued reply to '{sender}': {reply}")
def _format_path(
self, path_len: int, path_hashes: Optional[List[str]],
) -> str:
"""Format path info as ``path(N); 8D>A8`` or ``path(0)``.
Shows raw 1-byte hashes in uppercase hex.
"""
if not path_len:
return "path(0)"
if not path_hashes:
return f"path({path_len})"
hop_names = [h.upper() for h in path_hashes if h and len(h) >= 2]
if hop_names:
return f"path({path_len}); {'>'.join(hop_names)}"
return f"path({path_len})"
# ------------------------------------------------------------------
# Event callbacks
# ------------------------------------------------------------------
@@ -407,6 +522,16 @@ class BLEWorker:
f"path={decoded.path_hashes}"
)
# BOT: check for keyword and queue reply
self._bot_check_and_queue(
sender=decoded.sender,
text=decoded.text,
channel_idx=decoded.channel_idx,
snr=snr,
path_len=decoded.path_length,
path_hashes=decoded.path_hashes,
)
def _on_channel_msg(self, event) -> None:
"""Callback for channel messages — fallback only.
@@ -485,6 +610,15 @@ class BLEWorker:
'message_hash': msg_hash,
})
# BOT: check for keyword and queue reply (fallback path)
self._bot_check_and_queue(
sender=sender,
text=msg_text,
channel_idx=payload.get('channel_idx'),
snr=msg_snr,
path_len=payload.get('path_len', 0),
)
def _on_contact_msg(self, event) -> None:
"""Callback for received DMs; resolves sender name via pubkey."""
payload = event.payload

View File

@@ -55,3 +55,27 @@ CHANNELS_CONFIG: List[Dict] = [
TYPE_ICONS: Dict[int, str] = {0: "", 1: "📱", 2: "📡", 3: "🏠"}
TYPE_NAMES: Dict[int, str] = {0: "-", 1: "CLI", 2: "REP", 3: "ROOM"}
TYPE_LABELS: Dict[int, str] = {0: "-", 1: "Companion", 2: "Repeater", 3: "Room Server"}
# ==============================================================================
# BOT
# ==============================================================================
# Channel index the bot listens on (must match CHANNELS_CONFIG).
BOT_CHANNEL: int = 1 # #test
# Display name prepended to every bot reply.
BOT_NAME: str = "Zwolle Bot"
# Minimum seconds between two bot replies (prevents reply-storms).
BOT_COOLDOWN_SECONDS: float = 5.0
# Keyword → reply template mapping.
# Available variables: {bot}, {sender}, {snr}, {path}
# The bot checks whether the incoming message text *contains* the keyword
# (case-insensitive). First match wins.
BOT_KEYWORDS: Dict[str, str] = {
'test': '{bot}: {sender}, rcvd | SNR {snr} | {path}',
'ping': '{bot}: Pong!',
'help': '{bot}: test, ping, help',
}

View File

@@ -7,12 +7,23 @@ messaging, filters and RX log. The 500 ms update timer lives here.
from typing import Dict, List
import logging
from nicegui import ui
from meshcore_gui.config import TYPE_ICONS, TYPE_NAMES
from meshcore_gui.protocols import SharedDataReader
# Suppress the harmless "Client has been deleted" warning that NiceGUI
# emits when a browser tab is refreshed while a ui.timer is active.
class _DeletedClientFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
return 'Client has been deleted' not in record.getMessage()
logging.getLogger('nicegui').addFilter(_DeletedClientFilter())
class DashboardPage:
"""
Main dashboard rendered at ``/``.
@@ -30,6 +41,7 @@ class DashboardPage:
self._channel_select = None
self._channels_filter_container = None
self._channel_filters: Dict = {}
self._bot_checkbox = None
self._contacts_container = None
self._map_widget = None
self._messages_container = None
@@ -42,12 +54,23 @@ class DashboardPage:
# Channel data for message display
self._last_channels: List[Dict] = []
# Local first-render flag — each new browser tab gets its own
# DashboardPage instance and must render device/contacts once.
self._initialized: bool = False
# ------------------------------------------------------------------
# Public
# ------------------------------------------------------------------
def render(self) -> None:
"""Build the complete dashboard layout and start the timer."""
# Reset per-tab state — same DashboardPage instance is reused
# across browser reconnects, so force a fresh first-render.
self._initialized = False
self._markers.clear()
self._channel_filters = {}
self._last_channels = []
ui.dark_mode(False)
# Header
@@ -158,7 +181,7 @@ class DashboardPage:
return
data = self._shared.get_snapshot()
is_first = not data['gui_initialized']
is_first = not self._initialized
self._status_label.text = data['status']
@@ -181,6 +204,7 @@ class DashboardPage:
self._shared.clear_update_flags()
if is_first and data['channels'] and data['contacts']:
self._initialized = True
self._shared.mark_gui_initialized()
except Exception as e:
@@ -217,6 +241,17 @@ class DashboardPage:
self._channel_filters = {}
with self._channels_filter_container:
# BOT toggle — controls auto-reply, not display filtering
self._bot_checkbox = ui.checkbox(
'🤖 BOT',
value=data.get('bot_enabled', False),
on_change=lambda e: self._shared.set_bot_enabled(e.value),
)
# Visual separator
ui.label('').classes('text-gray-300')
# Display filters: DM + channels
cb_dm = ui.checkbox('DM', value=True)
self._channel_filters['DM'] = cb_dm
for ch in data['channels']:

View File

@@ -38,6 +38,8 @@ class SharedDataWriter(Protocol):
def get_next_command(self) -> Optional[Dict]: ...
def get_contact_name_by_prefix(self, pubkey_prefix: str) -> str: ...
def get_contact_by_name(self, name: str) -> Optional[tuple]: ...
def is_bot_enabled(self) -> bool: ...
def put_command(self, cmd: Dict) -> None: ...
# ----------------------------------------------------------------------
@@ -56,6 +58,7 @@ class SharedDataReader(Protocol):
def clear_update_flags(self) -> None: ...
def mark_gui_initialized(self) -> None: ...
def put_command(self, cmd: Dict) -> None: ...
def set_bot_enabled(self, enabled: bool) -> None: ...
# ----------------------------------------------------------------------

View File

@@ -79,6 +79,9 @@ class SharedData:
# Flag to track if GUI has done first render
self.gui_initialized: bool = False
# BOT enabled flag (toggled from GUI)
self.bot_enabled: bool = False
# ------------------------------------------------------------------
# Device info updates
# ------------------------------------------------------------------
@@ -118,6 +121,21 @@ class SharedData:
with self.lock:
self.connected = connected
# ------------------------------------------------------------------
# BOT
# ------------------------------------------------------------------
def set_bot_enabled(self, enabled: bool) -> None:
"""Toggle the BOT on or off."""
with self.lock:
self.bot_enabled = enabled
debug_print(f"BOT {'enabled' if enabled else 'disabled'}")
def is_bot_enabled(self) -> bool:
"""Return whether the BOT is currently enabled."""
with self.lock:
return self.bot_enabled
# ------------------------------------------------------------------
# Command queue
# ------------------------------------------------------------------
@@ -210,6 +228,7 @@ class SharedData:
'channels_updated': self.channels_updated,
'rxlog_updated': self.rxlog_updated,
'gui_initialized': self.gui_initialized,
'bot_enabled': self.bot_enabled,
}
def clear_update_flags(self) -> None: