Self-echo filter for notify webhook (skip own-node messages) (#22)
CI / build + unit (push) Successful in 43s

This commit was merged in pull request #22.
This commit is contained in:
2026-09-25 08:51:03 +02:00
parent 3d0e369230
commit d3d1913b83
6 changed files with 88 additions and 3 deletions
+74
View File
@@ -564,9 +564,23 @@ describe('MeshService message history', () => {
describe('MeshService notify webhook', () => {
const fetchMock = vi.fn(async (..._args: unknown[]) => undefined);
// our own node number for the self-echo filter specs (distinct from KAMIL_NUM, an external user)
const MY_NODE = 987654321;
beforeEach(() => fetchMock.mockClear());
// myInfo frame as streamed during the initial sync — sets svc.myNodeNum
function myInfoFrame(num: number): Uint8Array {
return frame(
toBinary(
P.Mesh.FromRadioSchema,
create(P.Mesh.FromRadioSchema, {
payloadVariant: { case: 'myInfo', value: { myNodeNum: num } },
}),
),
);
}
it('POSTs {text, from} to MESH_NOTIFY_URL when an rx text lands on the notify channel (default 3)', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
@@ -715,6 +729,66 @@ describe('MeshService notify webhook', () => {
delete process.env.MESH_NOTIFY_CHANNEL;
}
});
it('does not call fetch for rx texts from our own node on the 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_URL = 'http://notify.example/hook';
vi.stubGlobal('fetch', fetchMock);
const svc = new MeshService();
try {
svc.onModuleInit();
await waitFor(
() => bridge.sockets.length === 1 && svc.status().connected,
);
// initial sync: our own node number arrives via myInfo first
bridge.sockets[0].write(myInfoFrame(MY_NODE));
await waitFor(() => svc.status().myNodeNum === MY_NODE);
// an rx text from ourselves on the notify channel must not fire the webhook
bridge.sockets[0].write(textPacketFrame(MY_NODE, 42, 'ping', 3));
// history capture proves the packet was processed (notifyRx runs right after it)
await waitFor(() => svc.messages().length === 1);
expect(fetchMock).not.toHaveBeenCalled();
} finally {
vi.unstubAllGlobals();
delete process.env.MESH_NOTIFY_URL;
svc.onModuleDestroy();
bridge.close();
}
});
it('does not call fetch for an injected message from our own node', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port);
process.env.MESH_NOTIFY_URL = 'http://notify.example/hook';
process.env.MESH_NOTIFY_CHANNEL = '0';
vi.stubGlobal('fetch', fetchMock);
const svc = new MeshService();
try {
svc.onModuleInit();
await waitFor(
() => bridge.sockets.length === 1 && svc.status().connected,
);
bridge.sockets[0].write(myInfoFrame(MY_NODE));
await waitFor(() => svc.status().myNodeNum === MY_NODE);
svc.injectMessage('hi', MY_NODE);
expect(fetchMock).not.toHaveBeenCalled();
} finally {
vi.unstubAllGlobals();
delete process.env.MESH_NOTIFY_URL;
delete process.env.MESH_NOTIFY_CHANNEL;
svc.onModuleDestroy();
bridge.close();
}
});
});
describe('MeshService injectMessage', () => {
+5
View File
@@ -434,6 +434,11 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
/** Fire-and-forget notify-webhook POST for an rx text on `channel` (no-op unless URL is set and channels match). */
private notifyRx(channel: number, text: string, from?: number): void {
if (this.notifyUrl && channel === this.notifyChannel) {
// 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}`);
return;
}
const body: Record<string, unknown> = { text };
if (from !== undefined) body.from = from;
void fetch(this.notifyUrl, {