forked from iarv/meshcore-gui
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:
1
data/nodes.json
Normal file
1
data/nodes.json
Normal file
File diff suppressed because one or more lines are too long
BIN
meshcore-gui.zip
Normal file
BIN
meshcore-gui.zip
Normal file
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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']:
|
||||
|
||||
@@ -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: ...
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user