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 && (
|
{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)"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ VALID_SECTIONS = {
|
||||||
"history",
|
"history",
|
||||||
"memory",
|
"memory",
|
||||||
"context",
|
"context",
|
||||||
|
"meshcore_context",
|
||||||
"commands",
|
"commands",
|
||||||
"llm",
|
"llm",
|
||||||
"weather",
|
"weather",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
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