Skip to content

Commit cd82491

Browse files
Yerazeclaude
andauthored
feat(automations): multi-channel message trigger OR-list (#3974) (#3982)
The `trigger.message` block only exposed two single-value channel filters (`On channel (name)` and `On channel #`), so matching "channel A OR channel C" required a separate automation per channel. Add an `On channels` multi-select (`channelMulti`) to the message trigger — an OR-list that fires when the message's resolved channel name matches ANY selected entry. It mirrors the existing `action.sendMessage` `channels` field (name-based, unified across sources) and works for both Meshtastic and MeshCore messages. - catalog: add `channels` (channelMulti) to `trigger.message`, alongside the legacy scalar `channelName`/`channel` fields (kept for backward compat). - builder: no change needed — the trigger already receives the shared `channels` prop, so the existing channelMulti renderer wires up automatically. - triggerContext: all four matchers (messageMatchesFilter, meshCoreMessageMatchesFilter, describeMessageFilterMiss, describeMeshCoreFilterMiss) treat a populated `channels` array as an OR-list, taking precedence over the legacy scalar checks; empty/absent falls back to legacy behavior. New helpers messageFilterChannelNames / messageFilterUsesChannelName. - engine: onMessage/onMeshCoreMessage resolve the per-source slot→name when a channels array is present (not just the scalar channelName). No DB migration — saved automations keep working unchanged. Claude-Session: https://claude.ai/code/session_015AFBA76hsjqhsXe1BdnYub Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 64081b6 commit cd82491

7 files changed

Lines changed: 248 additions & 23 deletions

File tree

docs/features/automation-engine.md

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ Every automation has exactly one trigger (the **WHEN**). Each trigger exposes a
5656

5757
| Trigger | Fires when… | Notable options |
5858
| --- | --- | --- |
59-
| **A message is received** | A text/packet message arrives | `Text contains` (case-insensitive substring), `Text matches regex`, channel match, `From node #` |
59+
| **A message is received** | A text/packet message arrives | `Text contains` (case-insensitive substring), `Text matches regex`, multi-channel match (`On channels` OR-list), legacy `On channel (name)`/`On channel #`, `From node #` |
6060
| **A new node is discovered** | A node is seen for the first time ||
6161
| **A node is updated** | A node record changes (name, role, position, …) ||
6262
| **Telemetry is received** | A telemetry reading arrives | `Metric` filter (battery, voltage, temperature, channel utilization, air util TX, …) |
@@ -67,9 +67,23 @@ Every automation has exactly one trigger (the **WHEN**). Each trigger exposes a
6767
### Message trigger & channel-name matching
6868

6969
The message trigger can filter on text (substring or regex) and on the **channel**. Prefer matching
70-
by **channel name** (`On channel (name)`) rather than raw slot index: the same logical channel can
71-
sit in a different slot on different sources, so a name match is portable across your whole mesh.
72-
The raw `On channel #` index is still available for single-source cases.
70+
by **channel name** rather than raw slot index: the same logical channel can sit in a different slot
71+
on different sources, so a name match is portable across your whole mesh.
72+
73+
- **On channels** *(multi-select)* — pick one or more channels (unified by name across your
74+
sources). The trigger fires when a message arrives on **any** of the selected channels — an
75+
OR-list — so a single automation can cover "channel A **or** channel C" without a separate copy
76+
per channel. Leave none selected to match any channel. When set, this **overrides** the two
77+
single-channel fields below. Matching is by channel name and works for both Meshtastic and
78+
MeshCore messages.
79+
- **On channel (name)** — legacy single-channel name match (case-insensitive). Kept for
80+
backward compatibility with existing automations; the multi-select `On channels` above is
81+
preferred. Ignored when `On channels` is set.
82+
- **On channel #** — the raw slot index, still available for single-source cases. Ignored when
83+
`On channels` is set.
84+
85+
Saved automations that used the old single-channel fields keep working unchanged — no migration
86+
is needed.
7387

7488
### Schedule trigger (live cron)
7589

src/components/automations/catalog.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,9 @@ export const TRIGGERS: BlockDef[] = [
4747
fields: [
4848
{ name: 'textContains', label: 'Text contains', kind: 'text', placeholder: 'e.g. ping', help: 'Case-insensitive substring match. Leave blank to match any text.' },
4949
{ name: 'regex', label: 'Text matches regex', kind: 'text', placeholder: 'e.g. ^(test|ping)', advanced: true, help: 'A regular expression matched against the message text.' },
50-
{ name: 'channelName', label: 'On channel (name)', kind: 'text', placeholder: 'any', advanced: true, help: 'Match by channel name (case-insensitive) — portable across sources where the same channel sits in a different slot. Preferred over the channel # below.' },
51-
{ name: 'channel', label: 'On channel #', kind: 'number', placeholder: 'any', advanced: true, help: 'Match by raw channel index. Note: the same channel can be a different index on different sources — use the name above for cross-source automations.' },
50+
{ name: 'channels', label: 'On channels', kind: 'channelMulti', advanced: true, help: 'Match messages that arrive on ANY of these channels (unified by name across your sources). An OR-list — leave none to match any channel. When set, this overrides the single-channel fields below.' },
51+
{ name: 'channelName', label: 'On channel (name)', kind: 'text', placeholder: 'any', advanced: true, help: 'Match by channel name (case-insensitive) — portable across sources where the same channel sits in a different slot. Preferred over the channel # below. Ignored when "On channels" above is set.' },
52+
{ name: 'channel', label: 'On channel #', kind: 'number', placeholder: 'any', advanced: true, help: 'Match by raw channel index. Note: the same channel can be a different index on different sources — use the name above for cross-source automations. Ignored when "On channels" above is set.' },
5253
{ name: 'from', label: 'From node #', kind: 'number', placeholder: 'any', advanced: true, help: 'Only fire for messages from this node number.' },
5354
COOLDOWN,
5455
],

src/components/automations/compile.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, it, expect } from 'vitest';
22
import { compile, decompile, type WorkflowForm } from './compile.js';
33
import { validateAutomationGraph } from '../../types/automation.js';
4+
import { TRIGGERS } from './catalog.js';
45

56
const trig = { type: 'trigger.message', params: { textContains: 'ping' } };
67
const cond = (value: number) => ({ type: 'condition.numeric', params: { field: 'hops', op: '==', value } });
@@ -163,3 +164,30 @@ describe('decompile — fall-back to null', () => {
163164
expect(back!.rules[0].actions[0].type).toBe('action.tapback');
164165
});
165166
});
167+
168+
describe('trigger.message catalog — multi-channel field (#3974)', () => {
169+
const trigMsg = TRIGGERS.find((t) => t.type === 'trigger.message')!;
170+
171+
it('exposes a channelMulti "channels" field', () => {
172+
const f = trigMsg.fields.find((x) => x.name === 'channels');
173+
expect(f).toBeDefined();
174+
expect(f!.kind).toBe('channelMulti');
175+
});
176+
177+
it('keeps the legacy scalar channelName/channel fields for backward compat', () => {
178+
expect(trigMsg.fields.some((x) => x.name === 'channelName' && x.kind === 'text')).toBe(true);
179+
expect(trigMsg.fields.some((x) => x.name === 'channel' && x.kind === 'number')).toBe(true);
180+
});
181+
182+
it('round-trips a channels array through compile/decompile', () => {
183+
const channels = [{ name: 'gauntlet', protocol: 'meshtastic' }, { name: 'ops', protocol: 'meshtastic' }];
184+
const form: WorkflowForm = {
185+
trigger: { type: 'trigger.message', params: { channels } },
186+
rules: [{ conditions: [], actions: [{ type: 'action.tapback', params: { emoji: '👍' } }] }],
187+
combine: null,
188+
};
189+
const back = decompile(compile(form));
190+
expect(back).not.toBeNull();
191+
expect(back!.trigger.params).toEqual({ channels });
192+
});
193+
});

src/server/services/automation/automationEngineService.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,31 @@ describe('AutomationEngineService', () => {
145145
expect(calls.map((c) => c.fn)).toEqual(['sendTapback']);
146146
});
147147

148+
it('matches a message trigger by the multi-channel OR-list, resolving slot→name (#3974)', async () => {
149+
const { calls, deps } = recorder();
150+
await createEnabled('on-gauntlet-or-ops', {
151+
version: 1,
152+
nodes: [
153+
{ id: 't', type: 'trigger.message', params: { channels: [{ name: 'gauntlet', protocol: 'meshtastic' }, { name: 'ops', protocol: 'meshtastic' }] } },
154+
{ id: 'tap', type: 'action.tapback', params: { emoji: '👍' } },
155+
],
156+
edges: [{ from: 't', to: 'tap' }],
157+
});
158+
// Slot 2 = "Gauntlet", slot 3 = "Ops", everything else "Primary".
159+
const chData = {
160+
getNode: async () => null,
161+
getTelemetry: async () => null,
162+
getChannelName: async (_sourceId: string | null, idx: number) => (idx === 2 ? 'Gauntlet' : idx === 3 ? 'Ops' : 'Primary'),
163+
};
164+
const engine = new AutomationEngineService({ automationsRepo: autos, varResolver: resolver, deps, data: chData, now: () => clock });
165+
await engine.load();
166+
167+
expect(await engine.onMessage(message({ channel: 2 }), 'default')).toBe(1); // matches "gauntlet"
168+
expect(await engine.onMessage(message({ channel: 3 }), 'default')).toBe(1); // matches "ops"
169+
expect(await engine.onMessage(message({ channel: 0 }), 'default')).toBe(0); // "Primary" in neither
170+
expect(calls.map((c) => c.fn)).toEqual(['sendTapback', 'sendTapback']);
171+
});
172+
148173
it('fires a message automation on a MeshCore message and replies on the trigger scope (#3833)', async () => {
149174
const { calls, deps } = recorder();
150175
await createEnabled('mc-ping', {

src/server/services/automation/automationEngineService.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
meshCoreMessageMatchesFilter,
3434
describeMessageFilterMiss,
3535
describeMeshCoreFilterMiss,
36+
messageFilterUsesChannelName,
3637
type TriggerContext,
3738
type SystemEvent,
3839
} from './triggerContext.js';
@@ -384,7 +385,7 @@ export class AutomationEngineService {
384385
let channelName: string | null | undefined;
385386
const usesChannelName = (this.index.get('trigger.message') ?? []).some((a) => {
386387
const p = a.triggerNode.params as Record<string, unknown> | undefined;
387-
return typeof p?.channelName === 'string' && p.channelName.length > 0;
388+
return messageFilterUsesChannelName(p ?? {});
388389
});
389390
if (usesChannelName && this.data.getChannelName) {
390391
channelName = await this.data.getChannelName(sourceId, Number(msg.channel));
@@ -408,7 +409,7 @@ export class AutomationEngineService {
408409
let channelName: string | null | undefined;
409410
const usesChannelName = (this.index.get('trigger.message') ?? []).some((a) => {
410411
const p = a.triggerNode.params as Record<string, unknown> | undefined;
411-
return typeof p?.channelName === 'string' && p.channelName.length > 0;
412+
return messageFilterUsesChannelName(p ?? {});
412413
});
413414
// A received channel message stores its slot index in `from` as `channel-<idx>`.
414415
const channelIdx = ctx.fields.channel;

src/server/services/automation/triggerContext.test.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import {
1111
meshCoreMessageMatchesFilter,
1212
describeMessageFilterMiss,
1313
describeMeshCoreFilterMiss,
14+
messageFilterChannelNames,
15+
messageFilterUsesChannelName,
1416
resolveTriggerPath,
1517
BROADCAST_ADDR,
1618
} from './triggerContext.js';
@@ -140,6 +142,103 @@ describe('messageMatchesFilter', () => {
140142
});
141143
});
142144

145+
describe('multi-channel trigger filter (#3974)', () => {
146+
const chans = (...names: string[]) => names.map((name) => ({ name, protocol: 'meshtastic' }));
147+
148+
describe('messageFilterChannelNames', () => {
149+
it('extracts non-empty names from the channels array', () => {
150+
expect(messageFilterChannelNames({ channels: chans('gauntlet', 'ops') })).toEqual(['gauntlet', 'ops']);
151+
});
152+
it('drops blank names and ignores non-object entries', () => {
153+
expect(messageFilterChannelNames({ channels: [{ name: '' }, { name: 'ops' }, 'x', null, {}] })).toEqual(['ops']);
154+
});
155+
it('returns [] when channels is absent or not an array', () => {
156+
expect(messageFilterChannelNames({})).toEqual([]);
157+
expect(messageFilterChannelNames({ channels: 'nope' })).toEqual([]);
158+
});
159+
});
160+
161+
describe('messageFilterUsesChannelName', () => {
162+
it('is true for a scalar channelName', () => {
163+
expect(messageFilterUsesChannelName({ channelName: 'gauntlet' })).toBe(true);
164+
});
165+
it('is true for a populated channels array', () => {
166+
expect(messageFilterUsesChannelName({ channels: chans('gauntlet') })).toBe(true);
167+
});
168+
it('is false when neither is set / all blank', () => {
169+
expect(messageFilterUsesChannelName({})).toBe(false);
170+
expect(messageFilterUsesChannelName({ channelName: '' })).toBe(false);
171+
expect(messageFilterUsesChannelName({ channels: [{ name: '' }] })).toBe(false);
172+
});
173+
});
174+
175+
describe('messageMatchesFilter OR-list (Meshtastic)', () => {
176+
it('fires when the resolved name matches ANY entry (case-insensitive)', () => {
177+
expect(messageMatchesFilter(msg(), { channels: chans('gauntlet', 'ops') }, 'Gauntlet')).toBe(true);
178+
expect(messageMatchesFilter(msg(), { channels: chans('gauntlet', 'ops') }, 'OPS')).toBe(true);
179+
});
180+
it('does not fire when the resolved name matches no entry', () => {
181+
expect(messageMatchesFilter(msg(), { channels: chans('gauntlet', 'ops') }, 'Primary')).toBe(false);
182+
});
183+
it('never matches when the name could not be resolved', () => {
184+
expect(messageMatchesFilter(msg(), { channels: chans('gauntlet') }, null)).toBe(false);
185+
expect(messageMatchesFilter(msg(), { channels: chans('gauntlet') })).toBe(false);
186+
});
187+
it('an empty channels array is a legacy fallback (does not constrain)', () => {
188+
expect(messageMatchesFilter(msg(), { channels: [] }, null)).toBe(true);
189+
// empty array + legacy scalar channelName still honors the scalar
190+
expect(messageMatchesFilter(msg(), { channels: [], channelName: 'ops' }, 'Ops')).toBe(true);
191+
expect(messageMatchesFilter(msg(), { channels: [], channelName: 'ops' }, 'Primary')).toBe(false);
192+
});
193+
it('channels takes precedence over the legacy scalar channel/channelName', () => {
194+
// channels wins: the mismatched legacy scalar channelName is ignored.
195+
expect(messageMatchesFilter(msg(), { channels: chans('ops'), channelName: 'gauntlet' }, 'Ops')).toBe(true);
196+
// channels wins: the legacy numeric channel is ignored (msg.channel = 0, would fail on channel:2).
197+
expect(messageMatchesFilter(msg(), { channels: chans('ops'), channel: 2 }, 'Ops')).toBe(true);
198+
});
199+
it('still applies text/regex checks alongside the channel OR-list', () => {
200+
expect(messageMatchesFilter(msg({ text: 'ping' }), { channels: chans('ops'), textContains: 'ping' }, 'Ops')).toBe(true);
201+
expect(messageMatchesFilter(msg({ text: 'pong' }), { channels: chans('ops'), textContains: 'ping' }, 'Ops')).toBe(false);
202+
});
203+
});
204+
205+
describe('meshCoreMessageMatchesFilter OR-list (MeshCore)', () => {
206+
it('fires when the resolved name matches ANY entry', () => {
207+
expect(meshCoreMessageMatchesFilter(mcMsg({ fromPublicKey: 'channel-1' }), { channels: chans('gauntlet', 'ops') }, 'Ops')).toBe(true);
208+
expect(meshCoreMessageMatchesFilter(mcMsg({ fromPublicKey: 'channel-1' }), { channels: chans('gauntlet', 'ops') }, 'Primary')).toBe(false);
209+
});
210+
it('never matches when the name could not be resolved', () => {
211+
expect(meshCoreMessageMatchesFilter(mcMsg({ fromPublicKey: 'channel-1' }), { channels: chans('ops') }, null)).toBe(false);
212+
});
213+
it('empty channels array falls back to legacy scalar behavior', () => {
214+
expect(meshCoreMessageMatchesFilter(mcMsg({ fromPublicKey: 'channel-1' }), { channels: [] }, null)).toBe(true);
215+
expect(meshCoreMessageMatchesFilter(mcMsg({ fromPublicKey: 'channel-1' }), { channels: [], channelName: 'ops' }, 'Ops')).toBe(true);
216+
});
217+
it('channels precedence ignores the legacy numeric channel', () => {
218+
expect(meshCoreMessageMatchesFilter(mcMsg({ fromPublicKey: 'channel-1' }), { channels: chans('ops'), channel: 5 }, 'Ops')).toBe(true);
219+
});
220+
it('Meshtastic-only filters (from/to/portnum) still force a non-match', () => {
221+
expect(meshCoreMessageMatchesFilter(mcMsg({ fromPublicKey: 'channel-1' }), { channels: chans('ops'), from: 5 }, 'Ops')).toBe(false);
222+
});
223+
});
224+
225+
describe('describeMessageFilterMiss / describeMeshCoreFilterMiss OR-list reasons', () => {
226+
it('explains a Meshtastic multi-channel miss', () => {
227+
expect(describeMessageFilterMiss(msg(), { channels: chans('gauntlet', 'ops') }, 'Primary'))
228+
.toBe('channel name "Primary" not in [gauntlet, ops]');
229+
expect(describeMessageFilterMiss(msg(), { channels: chans('ops') }, null))
230+
.toBe('channel name "(unresolved)" not in [ops]');
231+
});
232+
it('returns undefined for a Meshtastic multi-channel hit', () => {
233+
expect(describeMessageFilterMiss(msg(), { channels: chans('gauntlet', 'ops') }, 'Ops')).toBeUndefined();
234+
});
235+
it('explains a MeshCore multi-channel miss', () => {
236+
expect(describeMeshCoreFilterMiss(mcMsg({ fromPublicKey: 'channel-1' }), { channels: chans('ops') }, 'Primary'))
237+
.toBe('channel name "Primary" not in [ops]');
238+
});
239+
});
240+
});
241+
143242
describe('buildMeshCoreMessageContext (#3833)', () => {
144243
it('maps a channel message: synthetic from, channel index, broadcast, scope', () => {
145244
const ctx = buildMeshCoreMessageContext(

0 commit comments

Comments
 (0)