mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
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:
parent
361e6d1bc4
commit
8b655669ef
10 changed files with 405 additions and 49 deletions
|
|
@ -916,21 +916,9 @@ function ContextSection({ data, onChange }: { data: ContextConfig; onChange: (d:
|
|||
/>
|
||||
{data.enabled && (
|
||||
<>
|
||||
<ChannelPicker
|
||||
label="Observe Channels"
|
||||
value={data.observe_channels}
|
||||
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."
|
||||
/>
|
||||
{/* Observe Channels + Ignore Nodes moved to the Meshtastic Connection
|
||||
page ("Bot behavior" section). max_age / max_context_items remain
|
||||
here as general context knobs. */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<NumberInput
|
||||
label="Max Age (sec)"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
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 { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig, getMeshcoreChannels, sendTestMessage } from '@/lib/api'
|
||||
import { useDirty } from '@/context/DirtyContext'
|
||||
|
|
@ -19,10 +19,24 @@ interface ConnectionConfig {
|
|||
[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() {
|
||||
const { setDirty } = useDirty()
|
||||
const [config, setConfig] = 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 [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(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<ConnectionConfig>,
|
||||
apiFetchConfig('meshcore_context') as Promise<MeshcoreContextCfg>,
|
||||
])
|
||||
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() {
|
|||
</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 */}
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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<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 [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(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<ConnectionConfig>,
|
||||
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)
|
||||
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() {
|
|||
<ConnectionSection data={config} onChange={setConfig} />
|
||||
</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 */}
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ VALID_SECTIONS = {
|
|||
"history",
|
||||
"memory",
|
||||
"context",
|
||||
"meshcore_context",
|
||||
"commands",
|
||||
"llm",
|
||||
"weather",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
50
work/tests/test_meshcore_context_config.py
Normal file
50
work/tests/test_meshcore_context_config.py
Normal 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
|
||||
85
work/tests/test_meshcore_context_filter.py
Normal file
85
work/tests/test_meshcore_context_filter.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue