mesh: MESH_NOTIFY_CHANNEL as comma-separated list + ch0 DM-only notify gate; v0.17.0
CI / build + unit (pull_request) Successful in 1m8s

This commit is contained in:
2026-09-25 16:18:07 +00:00
parent ff57f3eae1
commit 4ae932befe
8 changed files with 147 additions and 15 deletions
+1 -1
View File
@@ -245,7 +245,7 @@ export class MeshController {
@ApiOperation({
summary: 'Forwarded message history (last 100)',
description:
'The last 100 rx texts that passed the notify gate and were forwarded to opencode, newest first as { id, from?, text, channel, at }[] — channel matched MESH_NOTIFY_CHANNEL, not a self-echo, notifier wired. The id is the packet id (real rx) or generated record id (injected); `from` is omitted when there is no sender. In-memory only — reset on restart.',
'The last 100 rx texts that passed the notify gate and were forwarded to opencode, newest first as { id, from?, text, channel, at }[] — channel in the MESH_NOTIFY_CHANNEL comma-separated list (ch0: directed DMs only), not a self-echo, notifier wired. The id is the packet id (real rx) or generated record id (injected); `from` is omitted when there is no sender. In-memory only — reset on restart.',
})
@ApiResponse({
status: 200,
+106
View File
@@ -1046,6 +1046,112 @@ describe('MeshService opencode notifier', () => {
bridge.close();
}
});
it('notifies for rx texts on any channel in a comma-separated MESH_NOTIFY_CHANNEL list, and not for others', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port);
process.env.MESH_NOTIFY_CHANNEL = '2,5';
const svc = makeSvc();
try {
svc.onModuleInit();
await waitFor(
() => bridge.sockets.length === 1 && svc.status().connected,
);
// channel 5 is in the list — notified
bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, 'on-list', 5));
await waitFor(() => notifyMock.mock.calls.length === 1);
expect(notifyMock).toHaveBeenCalledWith(1, 'on-list', KAMIL_NUM);
// channel 3 is not in the list — captured in history but no notification
bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, 'off-list', 3));
await waitFor(() => svc.messages().length === 2);
expect(notifyMock).toHaveBeenCalledTimes(1);
} finally {
delete process.env.MESH_NOTIFY_CHANNEL;
svc.onModuleDestroy();
bridge.close();
}
});
it('ignores ch0 broadcasts even when 0 is in the list, but notifies for directed DMs', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port);
process.env.MESH_NOTIFY_CHANNEL = '3,0';
const svc = makeSvc();
try {
svc.onModuleInit();
await waitFor(
() => bridge.sockets.length === 1 && svc.status().connected,
);
// ch0 broadcast: shared default channel noise — no notification
bridge.sockets[0].write(
textPacketFrame(KAMIL_NUM, BROADCAST_ADDR, 'hello all', 0),
);
await waitFor(() => svc.messages().length === 1);
expect(notifyMock).not.toHaveBeenCalled();
// ch0 directed DM: both sender and recipient present — notified
bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, 'dm', 0));
await waitFor(() => notifyMock.mock.calls.length === 1);
expect(notifyMock).toHaveBeenCalledWith(1, 'dm', KAMIL_NUM);
} finally {
delete process.env.MESH_NOTIFY_CHANNEL;
svc.onModuleDestroy();
bridge.close();
}
});
it('notifies for a ch0 injection when 0 is in the list (multi-list)', () => {
process.env.MESH_NOTIFY_CHANNEL = '3,0';
const svc = makeSvc();
try {
const r = svc.injectMessage('hi', 42);
// injected records are exempt from the ch0 DM-only rule
expect(notifyMock).toHaveBeenCalledTimes(1);
expect(notifyMock).toHaveBeenCalledWith(r.id, 'hi', 42);
} finally {
delete process.env.MESH_NOTIFY_CHANNEL;
}
});
it('ignores empty and invalid entries in MESH_NOTIFY_CHANNEL', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port);
process.env.MESH_NOTIFY_CHANNEL = 'abc, ,5,-2,x7';
const svc = makeSvc();
try {
svc.onModuleInit();
await waitFor(
() => bridge.sockets.length === 1 && svc.status().connected,
);
// only the valid entry (5) survives parsing — notified
bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, 'ping', 5));
await waitFor(() => notifyMock.mock.calls.length === 1);
expect(notifyMock).toHaveBeenCalledWith(1, 'ping', KAMIL_NUM);
// channel 7 only appears inside the invalid entry "x7" — must not match
bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, 'nope', 7));
await waitFor(() => svc.messages().length === 2);
expect(notifyMock).toHaveBeenCalledTimes(1);
} finally {
delete process.env.MESH_NOTIFY_CHANNEL;
svc.onModuleDestroy();
bridge.close();
}
});
});
describe('MeshService forwarded ring', () => {
+25 -8
View File
@@ -161,6 +161,15 @@ function jsonSafe<T>(value: T): T {
return value; // primitives + undefined pass through
}
/** MESH_NOTIFY_CHANNEL is a comma-separated list of channel indices; empty/invalid entries are ignored. */
function parseNotifyChannels(raw: string): number[] {
return raw
.split(',')
.map((s) => s.trim())
.filter((s) => /^\d+$/.test(s))
.map((s) => parseInt(s, 10));
}
// key material must never leave the process — replaced with a marker so callers can see the field exists
const REDACTED = 'REDACTED';
/** Config sections that carry secrets: security (PKI keys) and network (wifi psk). */
@@ -190,11 +199,10 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
process.env.MESH_SEND_CHANNEL ?? '3',
10,
);
// outbound notification: fire-and-forget push to the opencode notifier when an rx text lands on the notify channel;
// outbound notification: fire-and-forget push to the opencode notifier when an rx text lands on one of the notify channels;
// disabled state (missing credentials) lives in OpencodeService itself
private readonly notifyChannel = parseInt(
private readonly notifyChannels = parseNotifyChannels(
process.env.MESH_NOTIFY_CHANNEL ?? '3',
10,
);
// how often the poller re-upserts the history buffer into SQLite (ms)
private readonly pollMs = parseInt(process.env.MESH_POLL_MS ?? '1000', 10);
@@ -506,19 +514,28 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
at: Date.now(),
};
this.pushHistory(rec);
// injected records ride channel 0 — the notifier fires only when MESH_NOTIFY_CHANNEL is set to match
this.notifyRx(0, text, from, rec.id);
// injected records ride channel 0 — the notifier fires only when 0 is in MESH_NOTIFY_CHANNEL (injections are exempt from the ch0 DM-only rule)
this.notifyRx(0, text, from, rec.id, undefined, true);
return { id: rec.id };
}
/** Fire-and-forget opencode notifier push for an rx text on `channel` (no-op unless channels match and a notifier is wired). Both call sites pass the packet id. */
/** Fire-and-forget opencode notifier push for an rx text on `channel` (no-op unless the channel is in the notify list and a notifier is wired). Both call sites pass the packet id; `injected` marks synthetic records that ride ch0 by design. */
private notifyRx(
channel: number,
text: string,
from?: number,
id?: number,
to?: number,
injected = false,
): void {
if (!this.opencode || channel !== this.notifyChannel) return;
if (!this.opencode || !this.notifyChannels.includes(channel)) return;
// ch0 is the shared default channel where other users talk — only directed DMs (a real destination) are notified there; broadcasts and undirected traffic are noise. Injected records ride ch0 by design, so they're exempt.
if (
channel === 0 &&
!injected &&
(to === undefined || to === BROADCAST_ADDR)
)
return;
// self-echo filter: our own node must never trigger a notification of itself
if (from !== undefined && from === this.myNodeNum) {
this.logger.debug(`notify skipped: rx from our own node ${from}`);
@@ -718,7 +735,7 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
channel: pkt.channel ?? 0,
at: Date.now(),
});
this.notifyRx(pkt.channel ?? 0, text, pkt.from, pkt.id);
this.notifyRx(pkt.channel ?? 0, text, pkt.from, pkt.id, pkt.to);
} else if (decoded && decoded.portnum === PORT_POSITION_APP) {
// live position update: the Position is serialized in Data.payload
let pos: Proto.Mesh.Position | undefined;