feat(dashboard): Phase C — MeshCore bot-behavior parity (observe channels / ignore contacts / DMs)

Add meshcore context (observe channels by name, ignore contacts, DM policy)
and wire the MeshCore inbound path to honor it, mirroring Meshtastic's
observe/ignore filtering. Symmetric "Bot behavior" sections on both
Connection pages. Meshtastic path unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Johnson 2026-07-03 17:12:32 +00:00
commit 8b655669ef
10 changed files with 405 additions and 49 deletions

View file

@ -916,21 +916,9 @@ function ContextSection({ data, onChange }: { data: ContextConfig; onChange: (d:
/> />
{data.enabled && ( {data.enabled && (
<> <>
<ChannelPicker {/* Observe Channels + Ignore Nodes moved to the Meshtastic Connection
label="Observe Channels" page ("Bot behavior" section). max_age / max_context_items remain
value={data.observe_channels} here as general context knobs. */}
onChange={(v) => 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"
/>
<NodePicker
label="Ignore Nodes"
value={data.ignore_nodes}
onChange={(v) => 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."
/>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<NumberInput <NumberInput
label="Max Age (sec)" label="Max Age (sec)"

View file

@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { Save, RotateCcw, RefreshCw, Check } from 'lucide-react' import { Save, RotateCcw, RefreshCw, Check } from 'lucide-react'
import { TextInput, NumberInput } from './Config' import { TextInput, NumberInput, Toggle, ListInput } from './Config'
import { notifyRestartRequired } from '@/components/RestartBanner' import { notifyRestartRequired } from '@/components/RestartBanner'
import { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig, getMeshcoreChannels, sendTestMessage } from '@/lib/api' import { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig, getMeshcoreChannels, sendTestMessage } from '@/lib/api'
import { useDirty } from '@/context/DirtyContext' import { useDirty } from '@/context/DirtyContext'
@ -19,10 +19,24 @@ interface ConnectionConfig {
[key: string]: unknown [key: string]: unknown
} }
// MeshCore-native "Bot behavior" config (section `meshcore_context`).
// observe_channels are channel NAMES; ignore_contacts are contact names or
// pubkey prefixes. Unknown fields are preserved on save via object spread.
interface MeshcoreContextCfg {
enable_passive_context?: boolean
observe_channels?: string[]
ignore_contacts?: string[]
respond_to_dms?: boolean
[key: string]: unknown
}
export default function MeshCoreConnection() { export default function MeshCoreConnection() {
const { setDirty } = useDirty() const { setDirty } = useDirty()
const [config, setConfig] = useState<ConnectionConfig | null>(null) const [config, setConfig] = useState<ConnectionConfig | null>(null)
const [originalConfig, setOriginalConfig] = useState<ConnectionConfig | null>(null) const [originalConfig, setOriginalConfig] = useState<ConnectionConfig | null>(null)
// Bot behavior (section `meshcore_context`)
const [mcContext, setMcContext] = useState<MeshcoreContextCfg | null>(null)
const [originalMcContext, setOriginalMcContext] = useState<MeshcoreContextCfg | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@ -40,9 +54,14 @@ export default function MeshCoreConnection() {
const fetchConfig = useCallback(async () => { const fetchConfig = useCallback(async () => {
setLoading(true) setLoading(true)
try { try {
const data = (await apiFetchConfig('connection')) as ConnectionConfig const [data, mcCtx] = await Promise.all([
apiFetchConfig('connection') as Promise<ConnectionConfig>,
apiFetchConfig('meshcore_context') as Promise<MeshcoreContextCfg>,
])
setConfig(data) setConfig(data)
setOriginalConfig(JSON.parse(JSON.stringify(data))) setOriginalConfig(JSON.parse(JSON.stringify(data)))
setMcContext(mcCtx)
setOriginalMcContext(JSON.parse(JSON.stringify(mcCtx)))
setHasChanges(false) setHasChanges(false)
setError(null) setError(null)
} catch (err) { } catch (err) {
@ -87,10 +106,13 @@ export default function MeshCoreConnection() {
} }
useEffect(() => { useEffect(() => {
if (config && originalConfig) { if (config && originalConfig && mcContext && originalMcContext) {
setHasChanges(JSON.stringify(config) !== JSON.stringify(originalConfig)) const changed =
JSON.stringify(config) !== JSON.stringify(originalConfig) ||
JSON.stringify(mcContext) !== JSON.stringify(originalMcContext)
setHasChanges(changed)
} }
}, [config, originalConfig]) }, [config, originalConfig, mcContext, originalMcContext])
useEffect(() => { useEffect(() => {
setDirty(hasChanges) setDirty(hasChanges)
@ -101,18 +123,23 @@ export default function MeshCoreConnection() {
setConfig((c) => (c ? { ...c, ...patch } : c)) setConfig((c) => (c ? { ...c, ...patch } : c))
const saveConfig = async () => { const saveConfig = async () => {
if (!config) return if (!config || !mcContext) return
setSaving(true) setSaving(true)
setError(null) setError(null)
setSuccess(null) setSuccess(null)
try { try {
// PUT the whole connection object so Meshtastic fields are preserved. // PUT the whole objects so sibling fields (Meshtastic connection fields,
const result = await apiUpdateConfig('connection', config) // 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))) setOriginalConfig(JSON.parse(JSON.stringify(config)))
setOriginalMcContext(JSON.parse(JSON.stringify(mcContext)))
setHasChanges(false) setHasChanges(false)
setDirty(false) setDirty(false)
setSuccess('MeshCore connection saved successfully') setSuccess('MeshCore connection saved successfully')
if (result.restart_required) { if (results.some((r) => r.restart_required)) {
notifyRestartRequired([]) notifyRestartRequired([])
} }
setTimeout(() => setSuccess(null), 3000) setTimeout(() => setSuccess(null), 3000)
@ -124,10 +151,20 @@ export default function MeshCoreConnection() {
} }
const discardChanges = () => { const discardChanges = () => {
if (originalConfig) { if (originalConfig) setConfig(JSON.parse(JSON.stringify(originalConfig)))
setConfig(JSON.parse(JSON.stringify(originalConfig))) if (originalMcContext) setMcContext(JSON.parse(JSON.stringify(originalMcContext)))
setHasChanges(false) 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) { if (loading) {
@ -228,6 +265,63 @@ export default function MeshCoreConnection() {
</div> </div>
</div> </div>
{/* Bot behavior card — mirrors the Meshtastic Connection page, MeshCore-native */}
{mcContext && (
<div className="bg-bg-card border border-border p-6 space-y-4">
<div className="text-xs text-slate-500 uppercase tracking-wide">Bot behavior</div>
<Toggle
label="Enable Passive Context"
checked={!!mcContext.enable_passive_context}
onChange={(v) => 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) */}
<div className="space-y-1">
<label className="block text-xs text-slate-500 uppercase tracking-wide">Observe MeshCore Channels</label>
<div className="border border-[#1e2a3a] p-2 space-y-1">
{channels.map((ch) => {
const selected = (mcContext.observe_channels ?? []).includes(ch)
return (
<label
key={ch}
onClick={() => toggleObserveChannel(ch)}
className="flex items-center gap-2 p-2 rounded hover:bg-[#0a0e17] cursor-pointer"
>
<div className={`w-4 h-4 rounded border flex items-center justify-center ${
selected ? 'bg-accent border-accent' : 'border-slate-600'
}`}>
{selected && <Check size={12} className="text-white" />}
</div>
<span className="text-sm text-slate-200">{ch}</span>
</label>
)
})}
{channels.length === 0 && (
<div className="text-sm text-slate-500 p-2">
No channels available{!channelsActive ? ' (MeshCore not connected)' : ''}
</div>
)}
</div>
<p className="text-xs text-slate-600">Channels to monitor (none selected = observe all)</p>
</div>
<ListInput
label="Ignore MeshCore Contacts"
value={mcContext.ignore_contacts ?? []}
onChange={(v) => 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."
/>
<Toggle
label="Respond to DMs"
checked={!!mcContext.respond_to_dms}
onChange={(v) => 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."
/>
</div>
)}
{/* Send test message card */} {/* Send test message card */}
<div className={`bg-bg-card border border-border p-6 space-y-4${!channelsActive ? ' opacity-60' : ''}`}> <div className={`bg-bg-card border border-border p-6 space-y-4${!channelsActive ? ' opacity-60' : ''}`}>
<div className="text-xs text-slate-500 uppercase tracking-wide">Send Test Message</div> <div className="text-xs text-slate-500 uppercase tracking-wide">Send Test Message</div>

View file

@ -1,14 +1,36 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import { Save, RotateCcw, RefreshCw, Check } from 'lucide-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 { notifyRestartRequired } from '@/components/RestartBanner'
import { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig, sendTestMessage } from '@/lib/api' import { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig, sendTestMessage } from '@/lib/api'
import { useDirty } from '@/context/DirtyContext' 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() { export default function MeshtasticConnection() {
const { setDirty } = useDirty() const { setDirty } = useDirty()
const [config, setConfig] = useState<ConnectionConfig | null>(null) const [config, setConfig] = useState<ConnectionConfig | null>(null)
const [originalConfig, setOriginalConfig] = useState<ConnectionConfig | null>(null) const [originalConfig, setOriginalConfig] = useState<ConnectionConfig | null>(null)
// Bot behavior: passive-context (section `context`) + respond-to-DMs (section `bot`)
const [context, setContext] = useState<ContextCfg | null>(null)
const [originalContext, setOriginalContext] = useState<ContextCfg | null>(null)
const [bot, setBot] = useState<BotCfg | null>(null)
const [originalBot, setOriginalBot] = useState<BotCfg | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@ -41,9 +63,17 @@ export default function MeshtasticConnection() {
const fetchConfig = useCallback(async () => { const fetchConfig = useCallback(async () => {
setLoading(true) setLoading(true)
try { try {
const data = (await apiFetchConfig('connection')) as ConnectionConfig const [conn, ctx, botData] = await Promise.all([
setConfig(data) apiFetchConfig('connection') as Promise<ConnectionConfig>,
setOriginalConfig(JSON.parse(JSON.stringify(data))) apiFetchConfig('context') as Promise<ContextCfg>,
apiFetchConfig('bot') as Promise<BotCfg>,
])
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) setHasChanges(false)
setError(null) setError(null)
} catch (err) { } catch (err) {
@ -59,10 +89,14 @@ export default function MeshtasticConnection() {
}, [fetchConfig]) }, [fetchConfig])
useEffect(() => { useEffect(() => {
if (config && originalConfig) { if (config && originalConfig && context && originalContext && bot && originalBot) {
setHasChanges(JSON.stringify(config) !== JSON.stringify(originalConfig)) 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(() => { useEffect(() => {
setDirty(hasChanges) setDirty(hasChanges)
@ -70,18 +104,25 @@ export default function MeshtasticConnection() {
}, [hasChanges, setDirty]) }, [hasChanges, setDirty])
const saveConfig = async () => { const saveConfig = async () => {
if (!config) return if (!config || !context || !bot) return
setSaving(true) setSaving(true)
setError(null) setError(null)
setSuccess(null) setSuccess(null)
try { try {
// PUT the whole connection object so MeshCore fields aren't clobbered. // PUT the whole objects so sibling fields (MeshCore connection fields,
const result = await apiUpdateConfig('connection', config) // 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))) setOriginalConfig(JSON.parse(JSON.stringify(config)))
setOriginalContext(JSON.parse(JSON.stringify(context)))
setOriginalBot(JSON.parse(JSON.stringify(bot)))
setHasChanges(false) setHasChanges(false)
setDirty(false) setDirty(false)
setSuccess('Meshtastic connection saved successfully') setSuccess('Meshtastic connection saved successfully')
if (result.restart_required) { if (results.some((r) => r.restart_required)) {
notifyRestartRequired([]) notifyRestartRequired([])
} }
setTimeout(() => setSuccess(null), 3000) setTimeout(() => setSuccess(null), 3000)
@ -93,10 +134,10 @@ export default function MeshtasticConnection() {
} }
const discardChanges = () => { const discardChanges = () => {
if (originalConfig) { if (originalConfig) setConfig(JSON.parse(JSON.stringify(originalConfig)))
setConfig(JSON.parse(JSON.stringify(originalConfig))) if (originalContext) setContext(JSON.parse(JSON.stringify(originalContext)))
setHasChanges(false) if (originalBot) setBot(JSON.parse(JSON.stringify(originalBot)))
} setHasChanges(false)
} }
if (loading) { if (loading) {
@ -167,6 +208,42 @@ export default function MeshtasticConnection() {
<ConnectionSection data={config} onChange={setConfig} /> <ConnectionSection data={config} onChange={setConfig} />
</div> </div>
{/* Bot behavior card */}
{context && bot && (
<div className="bg-bg-card border border-border p-6 space-y-4">
<div className="text-xs text-slate-500 uppercase tracking-wide">Bot behavior</div>
<Toggle
label="Enable Passive Context"
checked={!!context.enabled}
onChange={(v) => 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."
/>
<ChannelPicker
label="Observe Channels"
value={context.observe_channels ?? []}
onChange={(v) => 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"
/>
<NodePicker
label="Ignore Nodes"
value={context.ignore_nodes ?? []}
onChange={(v) => 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."
/>
<Toggle
label="Respond to DMs"
checked={!!bot.respond_to_dms}
onChange={(v) => 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."
/>
</div>
)}
{/* Send test message card */} {/* Send test message card */}
<div className="bg-bg-card border border-border p-6 space-y-4"> <div className="bg-bg-card border border-border p-6 space-y-4">
<div className="text-xs text-slate-500 uppercase tracking-wide">Send Test Message</div> <div className="text-xs text-slate-500 uppercase tracking-wide">Send Test Message</div>

View file

@ -104,6 +104,15 @@ class ContextConfig:
max_context_items: int = 20 # Max observations injected into LLM context 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 @dataclass
class CommandsConfig: class CommandsConfig:
"""Command settings.""" """Command settings."""
@ -782,6 +791,7 @@ class Config:
history: HistoryConfig = field(default_factory=HistoryConfig) history: HistoryConfig = field(default_factory=HistoryConfig)
memory: MemoryConfig = field(default_factory=MemoryConfig) memory: MemoryConfig = field(default_factory=MemoryConfig)
context: ContextConfig = field(default_factory=ContextConfig) context: ContextConfig = field(default_factory=ContextConfig)
meshcore_context: MeshCoreContextConfig = field(default_factory=MeshCoreContextConfig)
commands: CommandsConfig = field(default_factory=CommandsConfig) commands: CommandsConfig = field(default_factory=CommandsConfig)
llm: LLMConfig = field(default_factory=LLMConfig) llm: LLMConfig = field(default_factory=LLMConfig)
weather: WeatherConfig = field(default_factory=WeatherConfig) weather: WeatherConfig = field(default_factory=WeatherConfig)

View file

@ -45,6 +45,7 @@ VALID_SECTIONS = {
"history", "history",
"memory", "memory",
"context", "context",
"meshcore_context",
"commands", "commands",
"llm", "llm",
"weather", "weather",

View file

@ -395,7 +395,10 @@ class MeshAI:
await self._load_summaries() await self._load_summaries()
# Transport connector (factory derives backend from config.connection.meshcore_host) # 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 # Fit every broadcast handler's one-packet formatter to the active mesh
# transport's budget (LoRa max_chars, 140). Durable across adapter_config # transport's budget (LoRa max_chars, 140). Durable across adapter_config

View file

@ -3,7 +3,7 @@
from .base import MeshTransport 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. """Instantiate and return the active MeshTransport derived from config.
The active transports are derived from the connection config, not a The active transports are derived from the connection config, not a
@ -16,6 +16,8 @@ def build_transport(config) -> MeshTransport:
Args: Args:
config: A ConnectionConfig (or duck-compatible object). config: A ConnectionConfig (or duck-compatible object).
meshcore_context: Optional MeshCoreContextConfig for the MeshCore
passive-context / bot-behavior filter (None = pass-through).
Returns: Returns:
A concrete MeshTransport instance ready to be connected. A concrete MeshTransport instance ready to be connected.
@ -27,6 +29,9 @@ def build_transport(config) -> MeshTransport:
if meshcore_host.strip(): if meshcore_host.strip():
from meshai.transport.meshcore_transport import MeshCoreTransport from meshai.transport.meshcore_transport import MeshCoreTransport
from meshai.transport.composite_transport import CompositeTransport 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 return meshtastic

View file

@ -24,6 +24,30 @@ logger = logging.getLogger(__name__)
_COMMAND_TIMEOUT = 10.0 _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): class MeshCoreTransport(MeshTransport):
"""MeshTransport implementation over a pyMC companion TCP frame server. """MeshTransport implementation over a pyMC companion TCP frame server.
@ -41,8 +65,12 @@ class MeshCoreTransport(MeshTransport):
# Name tag used by CompositeTransport for routing hints. # Name tag used by CompositeTransport for routing hints.
transport_name: str = "meshcore" transport_name: str = "meshcore"
def __init__(self, config) -> None: def __init__(self, config, meshcore_context=None) -> None:
self.config = config 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._mc = None # meshcore.MeshCore instance
self._loop: Optional[asyncio.AbstractEventLoop] = None self._loop: Optional[asyncio.AbstractEventLoop] = None
self._loop_thread: Optional[threading.Thread] = 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).""" """Enumerated MeshCore channel names (from _chan_name_to_idx, populated at connect)."""
return list(self._chan_name_to_idx.keys()) 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) # Internal coroutines (run on the dedicated loop)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -416,13 +451,21 @@ class MeshCoreTransport(MeshTransport):
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _on_dm_event(self, event) -> None: 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) 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) self._dispatch_message(msg)
def _on_channel_event(self, event) -> None: 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) 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) self._dispatch_message(msg)
def _dispatch_message(self, msg: Optional[MeshMessage]) -> None: def _dispatch_message(self, msg: Optional[MeshMessage]) -> None:

View file

@ -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

View file

@ -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