mesh: MESH_NOTIFY_CHANNEL as comma-separated list + ch0 DM-only notify gate; v0.17.0
CI / build + unit (pull_request) Successful in 1m8s
CI / build + unit (pull_request) Successful in 1m8s
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user