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
+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;