diff --git a/work/dashboard-frontend/src/pages/Config.tsx b/work/dashboard-frontend/src/pages/Config.tsx index e216451..702cfc9 100644 --- a/work/dashboard-frontend/src/pages/Config.tsx +++ b/work/dashboard-frontend/src/pages/Config.tsx @@ -916,21 +916,9 @@ function ContextSection({ data, onChange }: { data: ContextConfig; onChange: (d: /> {data.enabled && ( <> - onChange({ ...data, observe_channels: v })} - helper="Channels to monitor (empty = all)" - info="Meshtastic channels to listen on. Leave empty to monitor all channels." - mode="multi" - /> - onChange({ ...data, ignore_nodes: v })} - helper="Nodes to exclude from context" - info="Messages from these nodes won't be included in passive context. Useful for filtering out noisy automated nodes." - /> + {/* Observe Channels + Ignore Nodes moved to the Meshtastic Connection + page ("Bot behavior" section). max_age / max_context_items remain + here as general context knobs. */}
(null) const [originalConfig, setOriginalConfig] = useState(null) + // Bot behavior (section `meshcore_context`) + const [mcContext, setMcContext] = useState(null) + const [originalMcContext, setOriginalMcContext] = useState(null) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [error, setError] = useState(null) @@ -40,9 +54,14 @@ export default function MeshCoreConnection() { const fetchConfig = useCallback(async () => { setLoading(true) try { - const data = (await apiFetchConfig('connection')) as ConnectionConfig + const [data, mcCtx] = await Promise.all([ + apiFetchConfig('connection') as Promise, + apiFetchConfig('meshcore_context') as Promise, + ]) setConfig(data) setOriginalConfig(JSON.parse(JSON.stringify(data))) + setMcContext(mcCtx) + setOriginalMcContext(JSON.parse(JSON.stringify(mcCtx))) setHasChanges(false) setError(null) } catch (err) { @@ -87,10 +106,13 @@ export default function MeshCoreConnection() { } useEffect(() => { - if (config && originalConfig) { - setHasChanges(JSON.stringify(config) !== JSON.stringify(originalConfig)) + if (config && originalConfig && mcContext && originalMcContext) { + const changed = + JSON.stringify(config) !== JSON.stringify(originalConfig) || + JSON.stringify(mcContext) !== JSON.stringify(originalMcContext) + setHasChanges(changed) } - }, [config, originalConfig]) + }, [config, originalConfig, mcContext, originalMcContext]) useEffect(() => { setDirty(hasChanges) @@ -101,18 +123,23 @@ export default function MeshCoreConnection() { setConfig((c) => (c ? { ...c, ...patch } : c)) const saveConfig = async () => { - if (!config) return + if (!config || !mcContext) return setSaving(true) setError(null) setSuccess(null) try { - // PUT the whole connection object so Meshtastic fields are preserved. - const result = await apiUpdateConfig('connection', config) + // PUT the whole objects so sibling fields (Meshtastic connection fields, + // any other meshcore_context keys) are preserved. + const results = await Promise.all([ + apiUpdateConfig('connection', config), + apiUpdateConfig('meshcore_context', mcContext), + ]) setOriginalConfig(JSON.parse(JSON.stringify(config))) + setOriginalMcContext(JSON.parse(JSON.stringify(mcContext))) setHasChanges(false) setDirty(false) setSuccess('MeshCore connection saved successfully') - if (result.restart_required) { + if (results.some((r) => r.restart_required)) { notifyRestartRequired([]) } setTimeout(() => setSuccess(null), 3000) @@ -124,10 +151,20 @@ export default function MeshCoreConnection() { } const discardChanges = () => { - if (originalConfig) { - setConfig(JSON.parse(JSON.stringify(originalConfig))) - setHasChanges(false) - } + if (originalConfig) setConfig(JSON.parse(JSON.stringify(originalConfig))) + if (originalMcContext) setMcContext(JSON.parse(JSON.stringify(originalMcContext))) + setHasChanges(false) + } + + const toggleObserveChannel = (name: string) => { + setMcContext((c) => { + if (!c) return c + const current = c.observe_channels ?? [] + const next = current.includes(name) + ? current.filter((n) => n !== name) + : [...current, name] + return { ...c, observe_channels: next } + }) } if (loading) { @@ -228,6 +265,63 @@ export default function MeshCoreConnection() {
+ {/* Bot behavior card — mirrors the Meshtastic Connection page, MeshCore-native */} + {mcContext && ( +
+
Bot behavior
+ setMcContext({ ...mcContext, enable_passive_context: v })} + helper="Listen to MeshCore channel traffic for context" + info="When enabled, the bot monitors MeshCore channels and includes recent messages in its context so it can reference what others said." + /> + {/* Observe MeshCore channels — multi-select of channel NAMES (empty = observe all) */} +
+ +
+ {channels.map((ch) => { + const selected = (mcContext.observe_channels ?? []).includes(ch) + return ( + + ) + })} + {channels.length === 0 && ( +
+ No channels available{!channelsActive ? ' (MeshCore not connected)' : ''} +
+ )} +
+

Channels to monitor (none selected = observe all)

+
+ setMcContext({ ...mcContext, ignore_contacts: v })} + helper="Contact names or pubkey prefixes to exclude from context (comma-separated)" + info="Messages from these MeshCore contacts won't be included in passive context. Enter contact names or public-key prefixes." + /> + setMcContext({ ...mcContext, respond_to_dms: v })} + helper="Reply when someone sends a MeshCore direct message" + info="When enabled, the bot responds to MeshCore direct messages. When disabled, it only responds to channel messages that mention its name." + /> +
+ )} + {/* Send test message card */}
Send Test Message
diff --git a/work/dashboard-frontend/src/pages/MeshtasticConnection.tsx b/work/dashboard-frontend/src/pages/MeshtasticConnection.tsx index 7f3d744..736562c 100644 --- a/work/dashboard-frontend/src/pages/MeshtasticConnection.tsx +++ b/work/dashboard-frontend/src/pages/MeshtasticConnection.tsx @@ -1,14 +1,36 @@ import { useState, useEffect, useCallback } from 'react' import { Save, RotateCcw, RefreshCw, Check } from 'lucide-react' -import { ConnectionSection, TextInput, NumberInput, type ConnectionConfig } from './Config' +import { ConnectionSection, TextInput, NumberInput, Toggle, type ConnectionConfig } from './Config' +import ChannelPicker from '@/components/ChannelPicker' +import NodePicker from '@/components/NodePicker' import { notifyRestartRequired } from '@/components/RestartBanner' import { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig, sendTestMessage } from '@/lib/api' import { useDirty } from '@/context/DirtyContext' +// Only the fields the "Bot behavior" section edits are typed explicitly; the +// rest of each section (max_age, max_context_items, MQTT fields, bot name/owner, +// etc.) is preserved untouched on save via object spread. +interface ContextCfg { + enabled?: boolean + observe_channels?: number[] + ignore_nodes?: string[] + [key: string]: unknown +} + +interface BotCfg { + respond_to_dms?: boolean + [key: string]: unknown +} + export default function MeshtasticConnection() { const { setDirty } = useDirty() const [config, setConfig] = useState(null) const [originalConfig, setOriginalConfig] = useState(null) + // Bot behavior: passive-context (section `context`) + respond-to-DMs (section `bot`) + const [context, setContext] = useState(null) + const [originalContext, setOriginalContext] = useState(null) + const [bot, setBot] = useState(null) + const [originalBot, setOriginalBot] = useState(null) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [error, setError] = useState(null) @@ -41,9 +63,17 @@ export default function MeshtasticConnection() { const fetchConfig = useCallback(async () => { setLoading(true) try { - const data = (await apiFetchConfig('connection')) as ConnectionConfig - setConfig(data) - setOriginalConfig(JSON.parse(JSON.stringify(data))) + const [conn, ctx, botData] = await Promise.all([ + apiFetchConfig('connection') as Promise, + apiFetchConfig('context') as Promise, + apiFetchConfig('bot') as Promise, + ]) + setConfig(conn) + setOriginalConfig(JSON.parse(JSON.stringify(conn))) + setContext(ctx) + setOriginalContext(JSON.parse(JSON.stringify(ctx))) + setBot(botData) + setOriginalBot(JSON.parse(JSON.stringify(botData))) setHasChanges(false) setError(null) } catch (err) { @@ -59,10 +89,14 @@ export default function MeshtasticConnection() { }, [fetchConfig]) useEffect(() => { - if (config && originalConfig) { - setHasChanges(JSON.stringify(config) !== JSON.stringify(originalConfig)) + if (config && originalConfig && context && originalContext && bot && originalBot) { + const changed = + JSON.stringify(config) !== JSON.stringify(originalConfig) || + JSON.stringify(context) !== JSON.stringify(originalContext) || + JSON.stringify(bot) !== JSON.stringify(originalBot) + setHasChanges(changed) } - }, [config, originalConfig]) + }, [config, originalConfig, context, originalContext, bot, originalBot]) useEffect(() => { setDirty(hasChanges) @@ -70,18 +104,25 @@ export default function MeshtasticConnection() { }, [hasChanges, setDirty]) const saveConfig = async () => { - if (!config) return + if (!config || !context || !bot) return setSaving(true) setError(null) setSuccess(null) try { - // PUT the whole connection object so MeshCore fields aren't clobbered. - const result = await apiUpdateConfig('connection', config) + // PUT the whole objects so sibling fields (MeshCore connection fields, + // context max_age/max_context_items, bot name/owner) aren't clobbered. + const results = await Promise.all([ + apiUpdateConfig('connection', config), + apiUpdateConfig('context', context), + apiUpdateConfig('bot', bot), + ]) setOriginalConfig(JSON.parse(JSON.stringify(config))) + setOriginalContext(JSON.parse(JSON.stringify(context))) + setOriginalBot(JSON.parse(JSON.stringify(bot))) setHasChanges(false) setDirty(false) setSuccess('Meshtastic connection saved successfully') - if (result.restart_required) { + if (results.some((r) => r.restart_required)) { notifyRestartRequired([]) } setTimeout(() => setSuccess(null), 3000) @@ -93,10 +134,10 @@ export default function MeshtasticConnection() { } const discardChanges = () => { - if (originalConfig) { - setConfig(JSON.parse(JSON.stringify(originalConfig))) - setHasChanges(false) - } + if (originalConfig) setConfig(JSON.parse(JSON.stringify(originalConfig))) + if (originalContext) setContext(JSON.parse(JSON.stringify(originalContext))) + if (originalBot) setBot(JSON.parse(JSON.stringify(originalBot))) + setHasChanges(false) } if (loading) { @@ -167,6 +208,42 @@ export default function MeshtasticConnection() {
+ {/* Bot behavior card */} + {context && bot && ( +
+
Bot behavior
+ setContext({ ...context, enabled: v })} + helper="Listen to channel traffic for context" + info="When enabled, the bot monitors mesh channels and includes recent messages in its context. This lets the bot reference things other people said on the channel." + /> + setContext({ ...context, observe_channels: v })} + helper="Channels to monitor (empty = all)" + info="Meshtastic channels to listen on. Leave empty to monitor all channels." + mode="multi" + /> + setContext({ ...context, ignore_nodes: v })} + helper="Nodes to exclude from context" + info="Messages from these nodes won't be included in passive context. Useful for filtering out noisy automated nodes." + /> + setBot({ ...bot, respond_to_dms: v })} + helper="Reply when someone sends a direct message" + info="When enabled, the bot responds to direct messages from any node. When disabled, the bot only responds to channel messages that mention its name." + /> +
+ )} + {/* Send test message card */}
Send Test Message
diff --git a/work/meshai/config.py b/work/meshai/config.py index 846cf3d..c53f710 100644 --- a/work/meshai/config.py +++ b/work/meshai/config.py @@ -104,6 +104,15 @@ class ContextConfig: max_context_items: int = 20 # Max observations injected into LLM context +@dataclass +class MeshCoreContextConfig: + """MeshCore passive-context / bot-behavior settings (MeshCore-native).""" + enable_passive_context: bool = True + observe_channels: list[str] = field(default_factory=list) # channel NAMES, empty = all + ignore_contacts: list[str] = field(default_factory=list) # contact names or pubkey prefixes + respond_to_dms: bool = True + + @dataclass class CommandsConfig: """Command settings.""" @@ -782,6 +791,7 @@ class Config: history: HistoryConfig = field(default_factory=HistoryConfig) memory: MemoryConfig = field(default_factory=MemoryConfig) context: ContextConfig = field(default_factory=ContextConfig) + meshcore_context: MeshCoreContextConfig = field(default_factory=MeshCoreContextConfig) commands: CommandsConfig = field(default_factory=CommandsConfig) llm: LLMConfig = field(default_factory=LLMConfig) weather: WeatherConfig = field(default_factory=WeatherConfig) diff --git a/work/meshai/dashboard/api/config_routes.py b/work/meshai/dashboard/api/config_routes.py index 2bd2797..af85c64 100644 --- a/work/meshai/dashboard/api/config_routes.py +++ b/work/meshai/dashboard/api/config_routes.py @@ -45,6 +45,7 @@ VALID_SECTIONS = { "history", "memory", "context", + "meshcore_context", "commands", "llm", "weather", diff --git a/work/meshai/main.py b/work/meshai/main.py index def9a1d..3c57d24 100644 --- a/work/meshai/main.py +++ b/work/meshai/main.py @@ -395,7 +395,10 @@ class MeshAI: await self._load_summaries() # Transport connector (factory derives backend from config.connection.meshcore_host) - self.connector = build_transport(self.config.connection) + self.connector = build_transport( + self.config.connection, + meshcore_context=self.config.meshcore_context, + ) # Fit every broadcast handler's one-packet formatter to the active mesh # transport's budget (LoRa max_chars, 140). Durable across adapter_config diff --git a/work/meshai/transport/factory.py b/work/meshai/transport/factory.py index f0adb27..b054e40 100644 --- a/work/meshai/transport/factory.py +++ b/work/meshai/transport/factory.py @@ -3,7 +3,7 @@ from .base import MeshTransport -def build_transport(config) -> MeshTransport: +def build_transport(config, meshcore_context=None) -> MeshTransport: """Instantiate and return the active MeshTransport derived from config. The active transports are derived from the connection config, not a @@ -16,6 +16,8 @@ def build_transport(config) -> MeshTransport: Args: config: A ConnectionConfig (or duck-compatible object). + meshcore_context: Optional MeshCoreContextConfig for the MeshCore + passive-context / bot-behavior filter (None = pass-through). Returns: A concrete MeshTransport instance ready to be connected. @@ -27,6 +29,9 @@ def build_transport(config) -> MeshTransport: if meshcore_host.strip(): from meshai.transport.meshcore_transport import MeshCoreTransport from meshai.transport.composite_transport import CompositeTransport - return CompositeTransport([meshtastic, MeshCoreTransport(config)], config=config) + return CompositeTransport( + [meshtastic, MeshCoreTransport(config, meshcore_context=meshcore_context)], + config=config, + ) return meshtastic diff --git a/work/meshai/transport/meshcore_transport.py b/work/meshai/transport/meshcore_transport.py index adcccd6..f1db82b 100644 --- a/work/meshai/transport/meshcore_transport.py +++ b/work/meshai/transport/meshcore_transport.py @@ -24,6 +24,30 @@ logger = logging.getLogger(__name__) _COMMAND_TIMEOUT = 10.0 +def mc_context_allows(cfg, msg, idx_to_name): + """Return True if a MeshCore inbound MeshMessage should be forwarded. + + cfg: MeshCoreContextConfig or None. idx_to_name: dict[int,str] channel-idx->name. + """ + if cfg is None: + return True + if msg.is_dm: + if not cfg.respond_to_dms: + return False + # ignore_contacts matches the pubkey prefix (sender_id) OR the contact name (sender_name) + if msg.sender_id in cfg.ignore_contacts or msg.sender_name in cfg.ignore_contacts: + return False + return True + # channel (non-DM) message -> only relevant for passive context + if not cfg.enable_passive_context: + return False + if cfg.observe_channels: + name = idx_to_name.get(msg.channel) + if name is None or name not in cfg.observe_channels: + return False + return True + + class MeshCoreTransport(MeshTransport): """MeshTransport implementation over a pyMC companion TCP frame server. @@ -41,8 +65,12 @@ class MeshCoreTransport(MeshTransport): # Name tag used by CompositeTransport for routing hints. transport_name: str = "meshcore" - def __init__(self, config) -> None: + def __init__(self, config, meshcore_context=None) -> None: self.config = config + # MeshCore passive-context / bot-behavior filter (MeshCoreContextConfig + # or None). None = pass-through (no filtering). Injected at construction + # time by the factory; can also be (re)set via set_context_config(). + self._mc_context = meshcore_context self._mc = None # meshcore.MeshCore instance self._loop: Optional[asyncio.AbstractEventLoop] = None self._loop_thread: Optional[threading.Thread] = None @@ -142,6 +170,13 @@ class MeshCoreTransport(MeshTransport): """Enumerated MeshCore channel names (from _chan_name_to_idx, populated at connect).""" return list(self._chan_name_to_idx.keys()) + def set_context_config(self, cfg) -> None: + """Set (or clear) the MeshCore passive-context filter config. + + cfg: MeshCoreContextConfig or None (None = pass-through). + """ + self._mc_context = cfg + # ------------------------------------------------------------------ # Internal coroutines (run on the dedicated loop) # ------------------------------------------------------------------ @@ -416,13 +451,21 @@ class MeshCoreTransport(MeshTransport): # ------------------------------------------------------------------ def _on_dm_event(self, event) -> None: - """Handle CONTACT_MSG_RECV: normalize and dispatch to meshai.""" + """Handle CONTACT_MSG_RECV: normalize, filter, and dispatch to meshai.""" msg = self._normalize_dm_event(event) + if msg is None or not mc_context_allows( + self._mc_context, msg, {v: k for k, v in self._chan_name_to_idx.items()} + ): + return self._dispatch_message(msg) def _on_channel_event(self, event) -> None: - """Handle CHANNEL_MSG_RECV: normalize and dispatch to meshai.""" + """Handle CHANNEL_MSG_RECV: normalize, filter, and dispatch to meshai.""" msg = self._normalize_channel_event(event) + if msg is None or not mc_context_allows( + self._mc_context, msg, {v: k for k, v in self._chan_name_to_idx.items()} + ): + return self._dispatch_message(msg) def _dispatch_message(self, msg: Optional[MeshMessage]) -> None: diff --git a/work/tests/test_meshcore_context_config.py b/work/tests/test_meshcore_context_config.py new file mode 100644 index 0000000..b9c1b73 --- /dev/null +++ b/work/tests/test_meshcore_context_config.py @@ -0,0 +1,50 @@ +"""Config round-trip tests for the MeshCore passive-context block. + +Verifies that ``meshcore_context`` survives save_config -> load_config, and +that a YAML lacking the section yields the dataclass defaults (proving the +generic nested-dataclass loader branch handles it with no special-casing). +""" + +import yaml + +from meshai.config import ( + Config, + MeshCoreContextConfig, + load_config, + save_config, +) + + +def test_meshcore_context_round_trip(tmp_path): + cfg = Config() + cfg.meshcore_context = MeshCoreContextConfig( + enable_passive_context=False, + observe_channels=["#aida", "#general"], + ignore_contacts=["a1b2", "SpamNode"], + respond_to_dms=False, + ) + + path = tmp_path / "config.yaml" + save_config(cfg, path) + loaded = load_config(path) + + mc = loaded.meshcore_context + assert isinstance(mc, MeshCoreContextConfig) + assert mc.enable_passive_context is False + assert mc.observe_channels == ["#aida", "#general"] + assert mc.ignore_contacts == ["a1b2", "SpamNode"] + assert mc.respond_to_dms is False + + +def test_meshcore_context_defaults_when_absent(tmp_path): + # A minimal YAML with no meshcore_context section at all. + path = tmp_path / "config.yaml" + path.write_text(yaml.safe_dump({"timezone": "America/Boise"})) + + loaded = load_config(path) + mc = loaded.meshcore_context + assert isinstance(mc, MeshCoreContextConfig) + assert mc.enable_passive_context is True + assert mc.observe_channels == [] + assert mc.ignore_contacts == [] + assert mc.respond_to_dms is True diff --git a/work/tests/test_meshcore_context_filter.py b/work/tests/test_meshcore_context_filter.py new file mode 100644 index 0000000..2b48059 --- /dev/null +++ b/work/tests/test_meshcore_context_filter.py @@ -0,0 +1,85 @@ +"""Unit tests for the MeshCore passive-context / bot-behavior filter. + +Covers the pure module-level helper ``mc_context_allows`` — no hardware, +no meshcore lib, no event loop. MeshMessage and MeshCoreContextConfig are +constructed directly. +""" + +from meshai.config import MeshCoreContextConfig +from meshai.connector import MeshMessage +from meshai.transport.meshcore_transport import mc_context_allows + + +def _dm(sender_id="a1b2", sender_name="Alice", text="hi"): + return MeshMessage( + sender_id=sender_id, + sender_name=sender_name, + text=text, + channel=0, + is_dm=True, + transport="meshcore", + ) + + +def _chan(channel=1, text="hello", sender_id=None, sender_name=None): + marker = f"chan:{channel}" + return MeshMessage( + sender_id=sender_id or marker, + sender_name=sender_name or marker, + text=text, + channel=channel, + is_dm=False, + transport="meshcore", + ) + + +def test_cfg_none_always_passes(): + assert mc_context_allows(None, _dm(), {}) is True + assert mc_context_allows(None, _chan(), {}) is True + + +def test_defaults_pass_through(): + cfg = MeshCoreContextConfig() # empty lists, passive on, respond_to_dms on + idx_to_name = {1: "#general"} + assert mc_context_allows(cfg, _chan(channel=1), idx_to_name) is True + assert mc_context_allows(cfg, _dm(), idx_to_name) is True + + +def test_observe_channels_filters_by_name(): + cfg = MeshCoreContextConfig(observe_channels=["#aida"]) + idx_to_name = {1: "#general", 2: "#aida"} + # idx 1 -> #general -> not in observe list -> DROPPED + assert mc_context_allows(cfg, _chan(channel=1), idx_to_name) is False + # idx 2 -> #aida -> in observe list -> PASSES + assert mc_context_allows(cfg, _chan(channel=2), idx_to_name) is True + # idx 3 -> no name mapping -> DROPPED + assert mc_context_allows(cfg, _chan(channel=3), idx_to_name) is False + + +def test_ignore_contacts_matches_id_or_name(): + cfg = MeshCoreContextConfig(ignore_contacts=["a1b2"]) + # matched by sender_id + assert mc_context_allows(cfg, _dm(sender_id="a1b2", sender_name="Alice"), {}) is False + # matched by sender_name + assert mc_context_allows(cfg, _dm(sender_id="ffff", sender_name="a1b2"), {}) is False + # unrelated DM passes + assert mc_context_allows(cfg, _dm(sender_id="c3d4", sender_name="Bob"), {}) is True + + +def test_respond_to_dms_false_drops_dms_only(): + cfg = MeshCoreContextConfig(respond_to_dms=False) + idx_to_name = {1: "#general"} + # any DM dropped + assert mc_context_allows(cfg, _dm(), idx_to_name) is False + assert mc_context_allows(cfg, _dm(sender_id="zzzz", sender_name="Zed"), idx_to_name) is False + # channel msgs unaffected (passive still on) + assert mc_context_allows(cfg, _chan(channel=1), idx_to_name) is True + + +def test_passive_disabled_drops_channel_but_dm_still_respected(): + cfg = MeshCoreContextConfig(enable_passive_context=False) + idx_to_name = {1: "#general"} + # channel msg dropped + assert mc_context_allows(cfg, _chan(channel=1), idx_to_name) is False + # DM still respects respond_to_dms (True by default) -> passes + assert mc_context_allows(cfg, _dm(), idx_to_name) is True