Merge pull request 'v0.15.0+v0.16.0: /api/respond reply endpoint + message-id in notifier wrapper, GET /api/forwarded ring' (#36) from feat/respond-endpoint into main
This commit is contained in:
@@ -1,5 +1,19 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.16.0 — 2026-09-25 15:49
|
||||||
|
|
||||||
|
- New `GET /api/forwarded`: the last 100 rx texts that passed the notify gate and were forwarded to opencode, newest first as `{ id, from?, text, channel, at }[]` — the id is the packet id (real rx) or generated record id (injected), `from` omitted when there is no sender. Backed by an in-memory ring buffer in `MeshService`, recorded in `notifyRx()` at hand-off: after the channel gate (`MESH_NOTIFY_CHANNEL`) and self-echo filter pass, right before the `OpencodeService.notify()` call; an unwired notifier records nothing (a notifier disabled internally still counts — MeshService cannot see its enabled flag). In-memory only, reset on restart. Purpose: a replying agent gets exactly the notified subset with packet ids instead of scanning all of `/api/messages`.
|
||||||
|
- Tests: 96 → 102 specs — ring records on a gated forward (shape + newest-first via `forwarded()`), no record on channel mismatch / self-echo / unwired notifier, injected messages count when they pass the gate (`from` omitted when absent), cap at 100 with oldest evicted.
|
||||||
|
- Docs: README endpoint bullet + curl example; AGENTS.md endpoints list kept in sync with the controller.
|
||||||
|
|
||||||
|
## v0.15.0 — 2026-09-25 15:40
|
||||||
|
|
||||||
|
- New `POST /api/respond` endpoint and MCP `respond(messageId, content)` tool: reply to an earlier message by packet id with automatic routing — the caller never thinks about where it goes. rx records are answered as a DM back to the original sender unless the original was a broadcast (then the reply is a broadcast), tx records go to their own destination, injected messages without a sender get a broadcast. The reply always rides the record's channel and carries `replyId = messageId`, so the recipient app renders it as a threaded quote of that packet. Returns `{ id }`; 400 on invalid messageId/content, 404 when no message has the given id (in memory or in SQLite), 503 when the bridge is down; MCP tool errors carry the same messages.
|
||||||
|
- Captured rx history records now persist their destination (`to: pkt.to`) — no schema change needed: the `to_node` column already existed for tx rows and flush/seed already round-tripped it, so captured packets simply fill it in too. This is what lets a reply tell a DM from a broadcast original.
|
||||||
|
- The opencode notifier wrapper now includes the packet id: msg = `New mesh message (id <packetId>)[ from node <n>]: "<text>" — please react to it.` — the id is the rx packet's id, or the generated record id for injected messages; the `from node` part is still omitted when absent.
|
||||||
|
- Tests: 88 → 96 specs. New: captured-rx row persists `to_node`; six `respond` service specs (rx DM → directed send back to sender on the original channel with wire-level replyId, broadcast original → broadcast reply, tx record → its destination/channel, SQLite fallback when the record is outside the in-memory buffer, 404 memory+DB miss, 400 validation matrix); MCP `respond` wiring spec (routing + unknown-id/out-of-range/empty-content tool errors) and the tool list grows to ten.
|
||||||
|
- Docs: README gains the endpoint bullet, a curl example, the MCP tool entry and the updated notifier wrapper format; AGENTS.md endpoints/MCP/notifier lines kept in sync with the controller.
|
||||||
|
|
||||||
## v0.14.0 — 2026-09-25 14:11
|
## v0.14.0 — 2026-09-25 14:11
|
||||||
|
|
||||||
- The standalone Python notify forwarder (`ops/forwarder`, compose service `forwarder`) is gone: its logic now lives in-process as a new `src/opencode` module — an `@Injectable()` `OpencodeService` registered in `MeshModule` and injected into `MeshService`. When an RX text lands on channel `MESH_NOTIFY_CHANNEL` (default 3), `MeshService.notifyRx()` calls `void this.opencode.notify(text, from)` instead of the old webhook POST; the channel gate and self-echo filter stay in `MeshService`, everything else (workspace/session resolution, message POST, retries) moved into the service.
|
- The standalone Python notify forwarder (`ops/forwarder`, compose service `forwarder`) is gone: its logic now lives in-process as a new `src/opencode` module — an `@Injectable()` `OpencodeService` registered in `MeshModule` and injected into `MeshService`. When an RX text lands on channel `MESH_NOTIFY_CHANNEL` (default 3), `MeshService.notifyRx()` calls `void this.opencode.notify(text, from)` instead of the old webhook POST; the channel gate and self-echo filter stay in `MeshService`, everything else (workspace/session resolution, message POST, retries) moved into the service.
|
||||||
|
|||||||
@@ -27,10 +27,12 @@ All API endpoints live under the `/api` prefix:
|
|||||||
|
|
||||||
- `GET /api/status` — `{ connected, myNodeNum, host, port }`
|
- `GET /api/status` — `{ connected, myNodeNum, host, port }`
|
||||||
- `POST /api/send` — body `{ "text": "...", "to"?: <node number | node name | "!b" | "broadcast">, "replyId"?: <packet id> }`, returns `{ id }`. Numbers pass through as-is (integer in 1..0xffffffff); names resolve like `GET /api/nodes/:id` (exact long/short name, then unique substring) — unknown or ambiguous targets are a 400. Optional `replyId` (integer in 1..0xffffffff) references an earlier message's packet id from `GET /api/messages` — when set, the recipient app renders the text as a threaded reply/quote of that packet
|
- `POST /api/send` — body `{ "text": "...", "to"?: <node number | node name | "!b" | "broadcast">, "replyId"?: <packet id> }`, returns `{ id }`. Numbers pass through as-is (integer in 1..0xffffffff); names resolve like `GET /api/nodes/:id` (exact long/short name, then unique substring) — unknown or ambiguous targets are a 400. Optional `replyId` (integer in 1..0xffffffff) references an earlier message's packet id from `GET /api/messages` — when set, the recipient app renders the text as a threaded reply/quote of that packet
|
||||||
|
- `POST /api/respond` — body `{ "messageId": <packet id>, "content": "..." }`, returns `{ id }`. Reply to an earlier message by packet id (from `GET /api/messages`) with automatic routing: rx records are answered as a DM back to the original sender unless the original was a broadcast (then the reply is a broadcast), tx records go to their destination, injected messages without a sender get a broadcast. The reply always rides the record's channel and carries `replyId = messageId`, so the recipient app renders it as a threaded quote of that packet. 400 on invalid messageId/content, 404 when no message has the given id (in memory or in SQLite), 503 when the bridge is down
|
||||||
- `GET /api/channels` — the device's Meshtastic channels as `{ index, name, uplinkEnabled, downlinkEnabled }[]` (psk/keys are redacted; empty until the initial sync completes)
|
- `GET /api/channels` — the device's Meshtastic channels as `{ index, name, uplinkEnabled, downlinkEnabled }[]` (psk/keys are redacted; empty until the initial sync completes)
|
||||||
- `GET /api/device` — device info captured during the initial sync: `{ myNodeNum, firmwareVersion?, metadata?, localConfig: {<section>: ...}, moduleConfig: {<module>: ...} }`. Key material is never exposed (security publicKey/privateKey/adminKey and network wifiPsk are replaced with `"REDACTED"`); in-memory only, reset on every bridge reconnect
|
- `GET /api/device` — device info captured during the initial sync: `{ myNodeNum, firmwareVersion?, metadata?, localConfig: {<section>: ...}, moduleConfig: {<module>: ...} }`. Key material is never exposed (security publicKey/privateKey/adminKey and network wifiPsk are replaced with `"REDACTED"`); in-memory only, reset on every bridge reconnect
|
||||||
- `GET /api/messages` — message history, newest first: inbound text (`dir:'rx'`, with `from`) and our sends (`dir:'tx'`, with `to` + `status`). tx records start `pending` and move to `delivered`/`failed` (with human-readable `ackError`) when the routing ACK/NACK arrives; broadcasts stay `pending` because firmware strips want_ack before TX. Backed by SQLite: the in-memory buffer (last 100) is seeded from the database on startup and re-upserted every second, so history survives process restarts and bridge reconnects; the database keeps the newest 1000 rows
|
- `GET /api/messages` — message history, newest first: inbound text (`dir:'rx'`, with `from` and `to`) and our sends (`dir:'tx'`, with `to` + `status`). tx records start `pending` and move to `delivered`/`failed` (with human-readable `ackError`) when the routing ACK/NACK arrives; broadcasts stay `pending` because firmware strips want_ack before TX. Backed by SQLite: the in-memory buffer (last 100) is seeded from the database on startup and re-upserted every second, so history survives process restarts and bridge reconnects; the database keeps the newest 1000 rows
|
||||||
- `POST /api/messages` — body `{ "text": "...", "from"?: <node number> }`, returns `{ id }`. Synthetic inbox injection for external triggers: records an rx entry in the history as if received on the mesh (channel 0) so external systems can push messages to trigger communication later. NOT transmitted over RF; appears in `GET /api/messages` like any other rx message
|
- `POST /api/messages` — body `{ "text": "...", "from"?: <node number> }`, returns `{ id }`. Synthetic inbox injection for external triggers: records an rx entry in the history as if received on the mesh (channel 0) so external systems can push messages to trigger communication later. NOT transmitted over RF; appears in `GET /api/messages` like any other rx message
|
||||||
|
- `GET /api/forwarded` — 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. The id is the packet id (real rx) or generated record id (injected); `from` omitted when there is no sender. In-memory only — reset on restart
|
||||||
- `GET /api/telemetry` — telemetry history, newest first: live `TELEMETRY_APP` packets from any node as `{ from, at /* epoch ms */, kind, data }[]`. `kind` is the `meshtastic.Telemetry` oneof case name (`deviceMetrics`, `environmentMetrics`, `airQualityMetrics`, `powerMetrics`, `localStats`, `healthMetrics`, `hostMetrics` or `trafficManagementStats`) and `data` carries that variant's payload (proto uint64 fields as decimal strings; unset optional fields omitted). In-memory only, last 100, reset on every bridge reconnect
|
- `GET /api/telemetry` — telemetry history, newest first: live `TELEMETRY_APP` packets from any node as `{ from, at /* epoch ms */, kind, data }[]`. `kind` is the `meshtastic.Telemetry` oneof case name (`deviceMetrics`, `environmentMetrics`, `airQualityMetrics`, `powerMetrics`, `localStats`, `healthMetrics`, `hostMetrics` or `trafficManagementStats`) and `data` carries that variant's payload (proto uint64 fields as decimal strings; unset optional fields omitted). In-memory only, last 100, reset on every bridge reconnect
|
||||||
- `GET /api/nodes` — known mesh nodes as `{ num, longName?, shortName?, hwModel?, batteryLevel?, lastHeard: number|null, position?: { lat, lon, alt? }, snr?, hopsAway?, viaMqtt? }[]`, sorted by node number. Collected during the initial sync plus live position updates; names/hwModel/battery appear only when reported, `lastHeard` is epoch seconds (null = never heard), `position` only when both coordinates are known, and snr/hopsAway/viaMqtt only when carried by the NodeInfo
|
- `GET /api/nodes` — known mesh nodes as `{ num, longName?, shortName?, hwModel?, batteryLevel?, lastHeard: number|null, position?: { lat, lon, alt? }, snr?, hopsAway?, viaMqtt? }[]`, sorted by node number. Collected during the initial sync plus live position updates; names/hwModel/battery appear only when reported, `lastHeard` is epoch seconds (null = never heard), `position` only when both coordinates are known, and snr/hopsAway/viaMqtt only when carried by the NodeInfo
|
||||||
- `GET /api/nodes/:id` — one node looked up by exact node number (numbers or numeric strings), then case-insensitive exact long/short name, else the unique case-insensitive substring match; 404 when nothing or more than one node matches
|
- `GET /api/nodes/:id` — one node looked up by exact node number (numbers or numeric strings), then case-insensitive exact long/short name, else the unique case-insensitive substring match; 404 when nothing or more than one node matches
|
||||||
@@ -42,10 +44,12 @@ Interactive API docs (Scalar) are served at `/`, with the OpenAPI JSON at
|
|||||||
curl http://localhost:3000/api/status
|
curl http://localhost:3000/api/status
|
||||||
curl -X POST http://localhost:3000/api/send -H 'content-type: application/json' -d '{"text":"hi"}'
|
curl -X POST http://localhost:3000/api/send -H 'content-type: application/json' -d '{"text":"hi"}'
|
||||||
curl -X POST http://localhost:3000/api/send -H 'content-type: application/json' -d '{"to":123456789,"text":"hello"}'
|
curl -X POST http://localhost:3000/api/send -H 'content-type: application/json' -d '{"to":123456789,"text":"hello"}'
|
||||||
|
curl -X POST http://localhost:3000/api/respond -H 'content-type: application/json' -d '{"messageId":123456789,"content":"on my way"}'
|
||||||
curl http://localhost:3000/api/channels
|
curl http://localhost:3000/api/channels
|
||||||
curl http://localhost:3000/api/device
|
curl http://localhost:3000/api/device
|
||||||
curl http://localhost:3000/api/messages
|
curl http://localhost:3000/api/messages
|
||||||
curl -X POST http://localhost:3000/api/messages -H 'content-type: application/json' -d '{"text":"external trigger"}'
|
curl -X POST http://localhost:3000/api/messages -H 'content-type: application/json' -d '{"text":"external trigger"}'
|
||||||
|
curl http://localhost:3000/api/forwarded
|
||||||
curl http://localhost:3000/api/telemetry
|
curl http://localhost:3000/api/telemetry
|
||||||
curl http://localhost:3000/api/nodes
|
curl http://localhost:3000/api/nodes
|
||||||
curl http://localhost:3000/api/nodes/kamil
|
curl http://localhost:3000/api/nodes/kamil
|
||||||
@@ -64,10 +68,12 @@ channel. Override with `MESH_SEND_CHANNEL`.
|
|||||||
The bot also exposes an MCP (Model Context Protocol) endpoint at
|
The bot also exposes an MCP (Model Context Protocol) endpoint at
|
||||||
`POST /mcp` (outside the `/api` prefix), served in-process over the SDK's
|
`POST /mcp` (outside the `/api` prefix), served in-process over the SDK's
|
||||||
Streamable HTTP transport in stateless mode. It maps 1:1 to the REST
|
Streamable HTTP transport in stateless mode. It maps 1:1 to the REST
|
||||||
endpoints — no new logic, same channel pinning:
|
endpoints — no new logic; sends ride the pinned channel like their REST
|
||||||
|
counterparts (`respond` rides the original message's channel):
|
||||||
|
|
||||||
- `mesh_status` — bridge connection state (`{ connected, myNodeNum, host, port }`)
|
- `mesh_status` — bridge connection state (`{ connected, myNodeNum, host, port }`)
|
||||||
- `send_message(text, to?, replyId?)` — send a text message; `to` resolves like `POST /api/send` (omitted/""/"!b"/"broadcast" → broadcast, number or numeric string → node number, else name lookup); optional `replyId` makes the message render as a threaded reply/quote of that packet id from `get_messages`; returns `{ id }`; unknown/ambiguous targets are tool errors
|
- `send_message(text, to?, replyId?)` — send a text message; `to` resolves like `POST /api/send` (omitted/""/"!b"/"broadcast" → broadcast, number or numeric string → node number, else name lookup); optional `replyId` makes the message render as a threaded reply/quote of that packet id from `get_messages`; returns `{ id }`; unknown/ambiguous targets are tool errors
|
||||||
|
- `respond(messageId, content)` — reply to an earlier message by packet id (from `get_messages`) with automatic routing: rx → DM back to the sender unless the original was a broadcast, tx → its destination, no-sender injected records → broadcast; rides the record's channel with `replyId = messageId` (threaded quote); returns `{ id }`; invalid args or an unknown id are tool errors
|
||||||
- `list_channels` — device channels (keys redacted)
|
- `list_channels` — device channels (keys redacted)
|
||||||
- `list_nodes` — known nodes as lean summaries
|
- `list_nodes` — known nodes as lean summaries
|
||||||
- `get_node(id)` — one node by number or name; tool error when missing/ambiguous
|
- `get_node(id)` — one node by number or name; tool error when missing/ambiguous
|
||||||
@@ -99,7 +105,7 @@ Hourly cron job (`src/weather`): fetches current conditions for Lublin from [Ope
|
|||||||
|
|
||||||
## Opencode notifier
|
## Opencode notifier
|
||||||
|
|
||||||
In-process service (`src/opencode`, no separate compose service): when an rx text message arrives on `MESH_NOTIFY_CHANNEL`, it pushes it to the mesh-bot folder's live opencode session (Basic auth). The opencode workspace UUID is regenerated on host restarts, so the notifier resolves it dynamically per request via the opencode API: `GET {OPENCODE_API}/api/workspaces` → match by `path == OPENCODE_WORKSPACE_PATH` (fallback name `mesh-bot`, status `ready` preferred when several match) → `GET {proxyPath}/session` → most recently updated session (`time.updated`; first entry if absent). No caching — messages are rare. The message POST body is exactly `{"parts":[{"type":"text","text":msg}]}` with msg = `New mesh message[ from node <n>]: "<text>" — please react to it.` (the `from node` part omitted when there is no sender). opencode accepts the message immediately but holds the HTTP response until the agent turn finishes, so a 15 s read timeout on that POST means **delivered** — treated as success, never retried; workspace/session GET failures are retried up to 3 times with backoff (2 s / 10 s / 30 s) before being dropped.
|
In-process service (`src/opencode`, no separate compose service): when an rx text message arrives on `MESH_NOTIFY_CHANNEL`, it pushes it to the mesh-bot folder's live opencode session (Basic auth). The opencode workspace UUID is regenerated on host restarts, so the notifier resolves it dynamically per request via the opencode API: `GET {OPENCODE_API}/api/workspaces` → match by `path == OPENCODE_WORKSPACE_PATH` (fallback name `mesh-bot`, status `ready` preferred when several match) → `GET {proxyPath}/session` → most recently updated session (`time.updated`; first entry if absent). No caching — messages are rare. The message POST body is exactly `{"parts":[{"type":"text","text":msg}]}` with msg = `New mesh message (id <packetId>)[ from node <n>]: "<text>" — please react to it.` (the `from node` part omitted when there is no sender; the packet id is the rx packet's id, or the generated record id for injected messages). opencode accepts the message immediately but holds the HTTP response until the agent turn finishes, so a 15 s read timeout on that POST means **delivered** — treated as success, never retried; workspace/session GET failures are retried up to 3 times with backoff (2 s / 10 s / 30 s) before being dropped.
|
||||||
|
|
||||||
Config lives in `.env` (gitignored) and is passed via compose environment — credentials are never committed to the repo:
|
Config lives in `.env` (gitignored) and is passed via compose environment — credentials are never committed to the repo:
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mesh-bot",
|
"name": "mesh-bot",
|
||||||
"version": "0.14.0",
|
"version": "0.16.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
@@ -148,6 +148,34 @@ export function telemetryPacketFrame(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Inbound text message: TEXT_MESSAGE_APP packet with UTF-8 in Data.payload (fixed packet id 1). */
|
||||||
|
export function textPacketFrame(
|
||||||
|
from: number,
|
||||||
|
to: number,
|
||||||
|
text: string,
|
||||||
|
channel = 0,
|
||||||
|
): Uint8Array {
|
||||||
|
const data = create(P.Mesh.DataSchema, {
|
||||||
|
portnum: P.Portnums.PortNum.TEXT_MESSAGE_APP,
|
||||||
|
payload: new TextEncoder().encode(text),
|
||||||
|
});
|
||||||
|
const pkt = create(P.Mesh.MeshPacketSchema, {
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
id: 1,
|
||||||
|
channel,
|
||||||
|
payloadVariant: { case: 'decoded', value: data },
|
||||||
|
});
|
||||||
|
return frame(
|
||||||
|
toBinary(
|
||||||
|
P.Mesh.FromRadioSchema,
|
||||||
|
create(P.Mesh.FromRadioSchema, {
|
||||||
|
payloadVariant: { case: 'packet', value: pkt },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const KAMIL_NUM = 123456789;
|
export const KAMIL_NUM = 123456789;
|
||||||
|
|
||||||
export function kamilNodeInfo(): NodeInfoFields {
|
export function kamilNodeInfo(): NodeInfoFields {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
nodeInfoFrame,
|
nodeInfoFrame,
|
||||||
startBridge,
|
startBridge,
|
||||||
telemetryPacketFrame,
|
telemetryPacketFrame,
|
||||||
|
textPacketFrame,
|
||||||
waitFor,
|
waitFor,
|
||||||
} from './fake-bridge.js';
|
} from './fake-bridge.js';
|
||||||
import { createMeshMcpServer } from './mcp.controller.js';
|
import { createMeshMcpServer } from './mcp.controller.js';
|
||||||
@@ -67,7 +68,7 @@ async function connectMcp(svc: MeshService): Promise<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('MCP server tools', () => {
|
describe('MCP server tools', () => {
|
||||||
it('lists all nine tools with descriptions and input schemas', async () => {
|
it('lists all ten tools with descriptions and input schemas', async () => {
|
||||||
const env = await connectedService();
|
const env = await connectedService();
|
||||||
try {
|
try {
|
||||||
const mcp = await connectMcp(env.svc);
|
const mcp = await connectMcp(env.svc);
|
||||||
@@ -82,6 +83,7 @@ describe('MCP server tools', () => {
|
|||||||
'list_channels',
|
'list_channels',
|
||||||
'list_nodes',
|
'list_nodes',
|
||||||
'mesh_status',
|
'mesh_status',
|
||||||
|
'respond',
|
||||||
'send_message',
|
'send_message',
|
||||||
]);
|
]);
|
||||||
for (const t of tools) expect(t.description?.length).toBeGreaterThan(0);
|
for (const t of tools) expect(t.description?.length).toBeGreaterThan(0);
|
||||||
@@ -100,6 +102,13 @@ describe('MCP server tools', () => {
|
|||||||
'text',
|
'text',
|
||||||
'from',
|
'from',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const respond = tools.find((t) => t.name === 'respond')!;
|
||||||
|
expect(respond.inputSchema.required).toEqual(['messageId', 'content']);
|
||||||
|
expect(Object.keys(respond.inputSchema.properties ?? {})).toEqual([
|
||||||
|
'messageId',
|
||||||
|
'content',
|
||||||
|
]);
|
||||||
} finally {
|
} finally {
|
||||||
await mcp.close();
|
await mcp.close();
|
||||||
}
|
}
|
||||||
@@ -196,6 +205,57 @@ describe('MCP server tools', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('respond routes the reply automatically and surfaces failures as tool errors', async () => {
|
||||||
|
const env = await connectedService();
|
||||||
|
try {
|
||||||
|
// rx DM from KAMIL on channel 3 (packet id 1)
|
||||||
|
env.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, 'ping', 3));
|
||||||
|
await waitFor(() => env.svc.messages().length === 1);
|
||||||
|
|
||||||
|
const mcp = await connectMcp(env.svc);
|
||||||
|
try {
|
||||||
|
const r1 = await mcp.client.callTool({
|
||||||
|
name: 'respond',
|
||||||
|
arguments: { messageId: 1, content: 'pong' },
|
||||||
|
});
|
||||||
|
expect(r1.isError).not.toBe(true);
|
||||||
|
const reply = JSON.parse(toolText(r1)) as { id: number };
|
||||||
|
expect(reply.id).toBeGreaterThan(0);
|
||||||
|
// DM back to the original sender on the original channel
|
||||||
|
expect(env.svc.messages()[0]).toMatchObject({
|
||||||
|
dir: 'tx',
|
||||||
|
to: KAMIL_NUM,
|
||||||
|
text: 'pong',
|
||||||
|
channel: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
const r2 = await mcp.client.callTool({
|
||||||
|
name: 'respond',
|
||||||
|
arguments: { messageId: 999, content: 'x' },
|
||||||
|
});
|
||||||
|
expect(r2.isError).toBe(true);
|
||||||
|
expect(toolText(r2)).toContain('no message with id 999');
|
||||||
|
|
||||||
|
const r3 = await mcp.client.callTool({
|
||||||
|
name: 'respond',
|
||||||
|
arguments: { messageId: 0, content: 'x' },
|
||||||
|
});
|
||||||
|
expect(r3.isError).toBe(true); // out-of-range rejected before the service sees it
|
||||||
|
|
||||||
|
const r4 = await mcp.client.callTool({
|
||||||
|
name: 'respond',
|
||||||
|
arguments: { messageId: 1, content: '' },
|
||||||
|
});
|
||||||
|
expect(r4.isError).toBe(true);
|
||||||
|
expect(toolText(r4)).toContain('content must be a non-empty string');
|
||||||
|
} finally {
|
||||||
|
await mcp.close();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
env.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('inject_message records an rx entry without sending over RF', async () => {
|
it('inject_message records an rx entry without sending over RF', async () => {
|
||||||
const env = await connectedService();
|
const env = await connectedService();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -46,6 +46,19 @@ export function createMeshMcpServer(mesh: MeshService): McpServer {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'respond',
|
||||||
|
{
|
||||||
|
description: `Reply to an earlier message by its packet id (from get_messages); returns { id } — the Meshtastic packet id of the reply. Routing is automatic: rx records are answered as a DM back to the original sender, tx records go to their destination; broadcast originals and injected messages without a sender get a broadcast. The reply always rides the record's channel and carries replyId = messageId (threaded quote in the recipient app). Invalid messageId/content or an unknown id are tool errors.`,
|
||||||
|
inputSchema: {
|
||||||
|
messageId: z.number().int().min(1).max(0xffffffff),
|
||||||
|
content: z.string(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ messageId, content }) =>
|
||||||
|
jsonResult(await mesh.respond(messageId, content)),
|
||||||
|
);
|
||||||
|
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
'list_channels',
|
'list_channels',
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -29,6 +29,17 @@ export class InjectMessageDto {
|
|||||||
from?: number;
|
from?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class RespondDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description:
|
||||||
|
'Packet id of the earlier message (from GET /api/messages) that this one replies to. Integer in 1..0xffffffff.',
|
||||||
|
example: 456789,
|
||||||
|
})
|
||||||
|
messageId!: number;
|
||||||
|
@ApiProperty({ description: 'Reply text', example: 'on my way' })
|
||||||
|
content!: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class SendTextDto {
|
export class SendTextDto {
|
||||||
@ApiProperty({ description: 'Message text', example: 'hello' })
|
@ApiProperty({ description: 'Message text', example: 'hello' })
|
||||||
text!: string;
|
text!: string;
|
||||||
@@ -111,6 +122,24 @@ export class MeshController {
|
|||||||
return { id: await this.mesh.sendText(to, body.text, replyId) };
|
return { id: await this.mesh.sendText(to, body.text, replyId) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('respond')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Reply to an earlier message by packet id',
|
||||||
|
description:
|
||||||
|
"Routing is automatic — the caller never thinks about where it goes: rx records are answered as a DM back to the original sender, tx records go to their destination; broadcast originals (and injected messages without a sender) get a broadcast. The reply always rides the record's channel and carries replyId = messageId, so the recipient app renders it as a threaded quote of that packet. Returns { id } with the new packet id.",
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Packet queued',
|
||||||
|
example: { id: 123456789 },
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 400, description: 'Invalid messageId or content' })
|
||||||
|
@ApiResponse({ status: 404, description: 'No message with the given id' })
|
||||||
|
@ApiResponse({ status: 503, description: 'Bridge not connected' })
|
||||||
|
async respond(@Body() body: RespondDto) {
|
||||||
|
return this.mesh.respond(body?.messageId, body?.content);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('channels')
|
@Get('channels')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "The device's Meshtastic channels (keys redacted)",
|
summary: "The device's Meshtastic channels (keys redacted)",
|
||||||
@@ -212,6 +241,29 @@ export class MeshController {
|
|||||||
return this.mesh.messages();
|
return this.mesh.messages();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('forwarded')
|
||||||
|
@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.',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Forwarded messages newest first',
|
||||||
|
example: [
|
||||||
|
{
|
||||||
|
id: 456789,
|
||||||
|
from: 987654321,
|
||||||
|
text: 'ping',
|
||||||
|
channel: 3,
|
||||||
|
at: 1758000000000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
forwarded() {
|
||||||
|
return this.mesh.forwarded();
|
||||||
|
}
|
||||||
|
|
||||||
@Post('messages')
|
@Post('messages')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Inject a synthetic inbound message into the history',
|
summary: 'Inject a synthetic inbound message into the history',
|
||||||
|
|||||||
+429
-38
@@ -1,5 +1,6 @@
|
|||||||
import { create, fromBinary, toBinary } from '@bufbuild/protobuf';
|
import { create, fromBinary, toBinary } from '@bufbuild/protobuf';
|
||||||
import * as P from '@meshtastic/protobufs';
|
import * as P from '@meshtastic/protobufs';
|
||||||
|
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||||
import { DatabaseSync } from 'node:sqlite';
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { BROADCAST_ADDR, extractFrames, frame } from './codec.js';
|
import { BROADCAST_ADDR, extractFrames, frame } from './codec.js';
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
nodeInfoFrame,
|
nodeInfoFrame,
|
||||||
startBridge,
|
startBridge,
|
||||||
telemetryPacketFrame,
|
telemetryPacketFrame,
|
||||||
|
textPacketFrame,
|
||||||
waitFor,
|
waitFor,
|
||||||
} from './fake-bridge.js';
|
} from './fake-bridge.js';
|
||||||
import { OpencodeService } from '../opencode/opencode.service.js';
|
import { OpencodeService } from '../opencode/opencode.service.js';
|
||||||
@@ -74,34 +76,6 @@ function nodeInfoPacketFrame(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// inbound text message: TEXT_MESSAGE_APP packet with UTF-8 in Data.payload
|
|
||||||
function textPacketFrame(
|
|
||||||
from: number,
|
|
||||||
to: number,
|
|
||||||
text: string,
|
|
||||||
channel = 0,
|
|
||||||
): Uint8Array {
|
|
||||||
const data = create(P.Mesh.DataSchema, {
|
|
||||||
portnum: P.Portnums.PortNum.TEXT_MESSAGE_APP,
|
|
||||||
payload: new TextEncoder().encode(text),
|
|
||||||
});
|
|
||||||
const pkt = create(P.Mesh.MeshPacketSchema, {
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
id: 1,
|
|
||||||
channel,
|
|
||||||
payloadVariant: { case: 'decoded', value: data },
|
|
||||||
});
|
|
||||||
return frame(
|
|
||||||
toBinary(
|
|
||||||
P.Mesh.FromRadioSchema,
|
|
||||||
create(P.Mesh.FromRadioSchema, {
|
|
||||||
payloadVariant: { case: 'packet', value: pkt },
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ACK/NACK for our sends: ROUTING_APP packet with Data.requestId = original id and a serialized Routing in the payload
|
// ACK/NACK for our sends: ROUTING_APP packet with Data.requestId = original id and a serialized Routing in the payload
|
||||||
function routingFrame(
|
function routingFrame(
|
||||||
requestId: number,
|
requestId: number,
|
||||||
@@ -594,6 +568,7 @@ describe('MeshService message history', () => {
|
|||||||
id: 1,
|
id: 1,
|
||||||
dir: 'rx',
|
dir: 'rx',
|
||||||
from: KAMIL_NUM,
|
from: KAMIL_NUM,
|
||||||
|
to: 42, // destination persisted on rx records (broadcast addr for broadcasts)
|
||||||
text: 'ping',
|
text: 'ping',
|
||||||
channel: 2,
|
channel: 2,
|
||||||
at: expect.any(Number),
|
at: expect.any(Number),
|
||||||
@@ -692,13 +667,237 @@ describe('MeshService message history', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// outbound text packets written to the bridge, in order (want_config frames skipped)
|
||||||
|
function outboundPackets(out: Uint8Array[]): P.Mesh.MeshPacket[] {
|
||||||
|
const merged = new Uint8Array(out.reduce((n, c) => n + c.length, 0));
|
||||||
|
let off = 0;
|
||||||
|
for (const c of out) {
|
||||||
|
merged.set(c, off);
|
||||||
|
off += c.length;
|
||||||
|
}
|
||||||
|
return extractFrames(merged).packets.flatMap((p) => {
|
||||||
|
const tr = fromBinary(P.Mesh.ToRadioSchema, p);
|
||||||
|
if (tr.payloadVariant?.case === 'packet') return [tr.payloadVariant.value];
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MeshService respond', () => {
|
||||||
|
it('replies to an rx DM as a directed send back to the sender on the original channel with replyId = messageId', async () => {
|
||||||
|
const bridge = await startBridge();
|
||||||
|
process.env.MESH_HOST = '127.0.0.1';
|
||||||
|
process.env.MESH_PORT = String(bridge.port);
|
||||||
|
const svc = new MeshService();
|
||||||
|
try {
|
||||||
|
svc.onModuleInit();
|
||||||
|
await waitFor(
|
||||||
|
() => bridge.sockets.length === 1 && svc.status().connected,
|
||||||
|
);
|
||||||
|
|
||||||
|
// rx DM from KAMIL to our node on channel 2 (packet id 1)
|
||||||
|
bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, 'ping', 2));
|
||||||
|
await waitFor(() => svc.messages().length === 1);
|
||||||
|
|
||||||
|
const out: Uint8Array[] = [];
|
||||||
|
bridge.sockets[0].on('data', (c) => out.push(c));
|
||||||
|
const { id } = await svc.respond(1, 'pong');
|
||||||
|
|
||||||
|
expect(id).toBeGreaterThan(0);
|
||||||
|
// DM back to the original sender on the original channel
|
||||||
|
expect(svc.messages()[0]).toMatchObject({
|
||||||
|
id,
|
||||||
|
dir: 'tx',
|
||||||
|
to: KAMIL_NUM,
|
||||||
|
text: 'pong',
|
||||||
|
channel: 2,
|
||||||
|
status: 'pending',
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => outboundPackets(out).length === 1);
|
||||||
|
const pkt = outboundPackets(out)[0]!;
|
||||||
|
expect(pkt.to).toBe(KAMIL_NUM);
|
||||||
|
expect(pkt.channel).toBe(2);
|
||||||
|
const data =
|
||||||
|
pkt.payloadVariant?.case === 'decoded'
|
||||||
|
? pkt.payloadVariant.value
|
||||||
|
: undefined;
|
||||||
|
expect(data?.replyId).toBe(1); // threaded quote of the original packet
|
||||||
|
} finally {
|
||||||
|
svc.onModuleDestroy();
|
||||||
|
bridge.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replies to a broadcast original as a broadcast on the original channel with replyId = messageId', async () => {
|
||||||
|
const bridge = await startBridge();
|
||||||
|
process.env.MESH_HOST = '127.0.0.1';
|
||||||
|
process.env.MESH_PORT = String(bridge.port);
|
||||||
|
const svc = new MeshService();
|
||||||
|
try {
|
||||||
|
svc.onModuleInit();
|
||||||
|
await waitFor(
|
||||||
|
() => bridge.sockets.length === 1 && svc.status().connected,
|
||||||
|
);
|
||||||
|
|
||||||
|
// rx broadcast from KAMIL on channel 2 (packet id 1)
|
||||||
|
bridge.sockets[0].write(
|
||||||
|
textPacketFrame(KAMIL_NUM, BROADCAST_ADDR, 'hello all', 2),
|
||||||
|
);
|
||||||
|
await waitFor(() => svc.messages().length === 1);
|
||||||
|
|
||||||
|
const out: Uint8Array[] = [];
|
||||||
|
bridge.sockets[0].on('data', (c) => out.push(c));
|
||||||
|
const { id } = await svc.respond(1, 'pong');
|
||||||
|
|
||||||
|
expect(id).toBeGreaterThan(0);
|
||||||
|
expect(svc.messages()[0]).toMatchObject({
|
||||||
|
dir: 'tx',
|
||||||
|
to: BROADCAST_ADDR, // broadcast original → broadcast reply
|
||||||
|
text: 'pong',
|
||||||
|
channel: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => outboundPackets(out).length === 1);
|
||||||
|
const pkt = outboundPackets(out)[0]!;
|
||||||
|
expect(pkt.to).toBe(BROADCAST_ADDR);
|
||||||
|
expect(pkt.channel).toBe(2);
|
||||||
|
const data =
|
||||||
|
pkt.payloadVariant?.case === 'decoded'
|
||||||
|
? pkt.payloadVariant.value
|
||||||
|
: undefined;
|
||||||
|
expect(data?.replyId).toBe(1);
|
||||||
|
} finally {
|
||||||
|
svc.onModuleDestroy();
|
||||||
|
bridge.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replies to a tx record targeting its destination on its channel with replyId = messageId', async () => {
|
||||||
|
const bridge = await startBridge();
|
||||||
|
process.env.MESH_HOST = '127.0.0.1';
|
||||||
|
process.env.MESH_PORT = String(bridge.port);
|
||||||
|
const svc = new MeshService();
|
||||||
|
try {
|
||||||
|
svc.onModuleInit();
|
||||||
|
await waitFor(
|
||||||
|
() => bridge.sockets.length === 1 && svc.status().connected,
|
||||||
|
);
|
||||||
|
|
||||||
|
const origId = await svc.sendText(42, 'orig'); // tx record: to=42, default channel 3
|
||||||
|
const out: Uint8Array[] = [];
|
||||||
|
bridge.sockets[0].on('data', (c) => out.push(c));
|
||||||
|
const { id } = await svc.respond(origId, 'reply');
|
||||||
|
|
||||||
|
expect(id).toBeGreaterThan(0);
|
||||||
|
expect(svc.messages()[0]).toMatchObject({
|
||||||
|
dir: 'tx',
|
||||||
|
to: 42, // the tx record's own destination
|
||||||
|
text: 'reply',
|
||||||
|
channel: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
// the orig send may share a chunk with the reply — take the newest packet
|
||||||
|
await waitFor(() => outboundPackets(out).length >= 1);
|
||||||
|
const pkts = outboundPackets(out);
|
||||||
|
const pkt = pkts[pkts.length - 1]!;
|
||||||
|
expect(pkt.to).toBe(42);
|
||||||
|
const data =
|
||||||
|
pkt.payloadVariant?.case === 'decoded'
|
||||||
|
? pkt.payloadVariant.value
|
||||||
|
: undefined;
|
||||||
|
expect(data?.replyId).toBe(origId);
|
||||||
|
} finally {
|
||||||
|
svc.onModuleDestroy();
|
||||||
|
bridge.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundException when no message has the given id (memory or SQLite)', async () => {
|
||||||
|
const svc = new MeshService();
|
||||||
|
await expect(svc.respond(999, 'hi')).rejects.toBeInstanceOf(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
|
await expect(svc.respond(999, 'hi')).rejects.toThrow(
|
||||||
|
'no message with id 999',
|
||||||
|
);
|
||||||
|
|
||||||
|
const db = new DatabaseSync(':memory:'); // empty table — the DB lookup path also misses
|
||||||
|
initMessagesTable(db);
|
||||||
|
const svc2 = new MeshService(db);
|
||||||
|
await expect(svc2.respond(999, 'hi')).rejects.toBeInstanceOf(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to SQLite when the record is not in the in-memory buffer', async () => {
|
||||||
|
const db = new DatabaseSync(':memory:');
|
||||||
|
initMessagesTable(db);
|
||||||
|
// 150 rows — onModuleInit seeds only the newest 100 into the buffer (ids 2050..2149)
|
||||||
|
const ins = db.prepare(
|
||||||
|
'INSERT INTO messages(id, dir, to_node, from_node, text, channel, at, status, ack_error) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||||
|
);
|
||||||
|
for (let i = 0; i < 150; i++)
|
||||||
|
ins.run(
|
||||||
|
2000 + i,
|
||||||
|
'rx',
|
||||||
|
42,
|
||||||
|
KAMIL_NUM,
|
||||||
|
`old-${i}`,
|
||||||
|
2,
|
||||||
|
1_700_000_000_000 + i * 1000,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const bridge = await startBridge();
|
||||||
|
process.env.MESH_HOST = '127.0.0.1';
|
||||||
|
process.env.MESH_PORT = String(bridge.port);
|
||||||
|
const svc = new MeshService(db);
|
||||||
|
try {
|
||||||
|
svc.onModuleInit();
|
||||||
|
await waitFor(
|
||||||
|
() => bridge.sockets.length === 1 && svc.status().connected,
|
||||||
|
);
|
||||||
|
|
||||||
|
// id 2000 is in SQLite but outside the seeded buffer
|
||||||
|
const { id } = await svc.respond(2000, 'pong');
|
||||||
|
expect(id).toBeGreaterThan(0);
|
||||||
|
expect(svc.messages()[0]).toMatchObject({
|
||||||
|
dir: 'tx',
|
||||||
|
to: KAMIL_NUM, // DM back to the sender of the old rx record
|
||||||
|
text: 'pong',
|
||||||
|
channel: 2, // the record's channel
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
svc.onModuleDestroy();
|
||||||
|
bridge.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid messageId and empty content with BadRequestException', async () => {
|
||||||
|
const svc = new MeshService();
|
||||||
|
for (const bad of [0, BROADCAST_ADDR + 1, 42.5, 'x' as unknown as number])
|
||||||
|
await expect(svc.respond(bad, 'hi')).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
await expect(svc.respond(1, '')).rejects.toThrow(
|
||||||
|
'content must be a non-empty string',
|
||||||
|
);
|
||||||
|
await expect(svc.respond(1, undefined)).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('MeshService opencode notifier', () => {
|
describe('MeshService opencode notifier', () => {
|
||||||
// our own node number for the self-echo filter specs (distinct from KAMIL_NUM, an external user)
|
// our own node number for the self-echo filter specs (distinct from KAMIL_NUM, an external user)
|
||||||
const MY_NODE = 987654321;
|
const MY_NODE = 987654321;
|
||||||
let notifyMock: ReturnType<typeof vi.fn>;
|
let notifyMock: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
notifyMock = vi.fn(async (_text: string, _from?: number) => {});
|
notifyMock = vi.fn(
|
||||||
|
async (_id: number, _text: string, _from?: number) => {},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// myInfo frame as streamed during the initial sync — sets svc.myNodeNum
|
// myInfo frame as streamed during the initial sync — sets svc.myNodeNum
|
||||||
@@ -715,10 +914,9 @@ describe('MeshService opencode notifier', () => {
|
|||||||
|
|
||||||
// MeshService with a mocked OpencodeService wired in (structural cast — only notify() is used)
|
// MeshService with a mocked OpencodeService wired in (structural cast — only notify() is used)
|
||||||
function makeSvc(): MeshService {
|
function makeSvc(): MeshService {
|
||||||
return new MeshService(
|
return new MeshService(undefined, {
|
||||||
undefined,
|
notify: notifyMock,
|
||||||
{ notify: notifyMock } as unknown as OpencodeService,
|
} as unknown as OpencodeService);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
it('notifies via the opencode service when an rx text lands on the notify channel (default 3)', async () => {
|
it('notifies via the opencode service when an rx text lands on the notify channel (default 3)', async () => {
|
||||||
@@ -737,7 +935,7 @@ describe('MeshService opencode notifier', () => {
|
|||||||
|
|
||||||
await waitFor(() => notifyMock.mock.calls.length === 1);
|
await waitFor(() => notifyMock.mock.calls.length === 1);
|
||||||
expect(notifyMock).toHaveBeenCalledTimes(1);
|
expect(notifyMock).toHaveBeenCalledTimes(1);
|
||||||
expect(notifyMock).toHaveBeenCalledWith('ping', KAMIL_NUM);
|
expect(notifyMock).toHaveBeenCalledWith(1, 'ping', KAMIL_NUM); // packet id threaded through
|
||||||
} finally {
|
} finally {
|
||||||
delete process.env.MESH_NOTIFY_CHANNEL;
|
delete process.env.MESH_NOTIFY_CHANNEL;
|
||||||
svc.onModuleDestroy();
|
svc.onModuleDestroy();
|
||||||
@@ -773,10 +971,10 @@ describe('MeshService opencode notifier', () => {
|
|||||||
process.env.MESH_NOTIFY_CHANNEL = '0';
|
process.env.MESH_NOTIFY_CHANNEL = '0';
|
||||||
const svc = makeSvc();
|
const svc = makeSvc();
|
||||||
try {
|
try {
|
||||||
svc.injectMessage('hi', 42);
|
const r = svc.injectMessage('hi', 42);
|
||||||
|
|
||||||
expect(notifyMock).toHaveBeenCalledTimes(1);
|
expect(notifyMock).toHaveBeenCalledTimes(1);
|
||||||
expect(notifyMock).toHaveBeenCalledWith('hi', 42);
|
expect(notifyMock).toHaveBeenCalledWith(r.id, 'hi', 42); // generated record id threaded through
|
||||||
} finally {
|
} finally {
|
||||||
delete process.env.MESH_NOTIFY_CHANNEL;
|
delete process.env.MESH_NOTIFY_CHANNEL;
|
||||||
}
|
}
|
||||||
@@ -786,10 +984,10 @@ describe('MeshService opencode notifier', () => {
|
|||||||
process.env.MESH_NOTIFY_CHANNEL = '0';
|
process.env.MESH_NOTIFY_CHANNEL = '0';
|
||||||
const svc = makeSvc();
|
const svc = makeSvc();
|
||||||
try {
|
try {
|
||||||
svc.injectMessage('hi');
|
const r = svc.injectMessage('hi');
|
||||||
|
|
||||||
expect(notifyMock).toHaveBeenCalledTimes(1);
|
expect(notifyMock).toHaveBeenCalledTimes(1);
|
||||||
expect(notifyMock).toHaveBeenCalledWith('hi', undefined);
|
expect(notifyMock).toHaveBeenCalledWith(r.id, 'hi', undefined);
|
||||||
} finally {
|
} finally {
|
||||||
delete process.env.MESH_NOTIFY_CHANNEL;
|
delete process.env.MESH_NOTIFY_CHANNEL;
|
||||||
}
|
}
|
||||||
@@ -850,6 +1048,161 @@ describe('MeshService opencode notifier', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('MeshService forwarded ring', () => {
|
||||||
|
const MY_NODE = 987654321;
|
||||||
|
let notifyMock: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
notifyMock = vi.fn(
|
||||||
|
async (_id: number, _text: string, _from?: number) => {},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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 } },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// MeshService with a mocked OpencodeService wired in (structural cast — only notify() is used)
|
||||||
|
function makeSvc(): MeshService {
|
||||||
|
return new MeshService(undefined, {
|
||||||
|
notify: notifyMock,
|
||||||
|
} as unknown as OpencodeService);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('records an entry when an rx text passes the gate and is handed off (channel match)', async () => {
|
||||||
|
const bridge = await startBridge();
|
||||||
|
process.env.MESH_HOST = '127.0.0.1';
|
||||||
|
process.env.MESH_PORT = String(bridge.port);
|
||||||
|
delete process.env.MESH_NOTIFY_CHANNEL; // default 3
|
||||||
|
const svc = makeSvc();
|
||||||
|
try {
|
||||||
|
svc.onModuleInit();
|
||||||
|
await waitFor(
|
||||||
|
() => bridge.sockets.length === 1 && svc.status().connected,
|
||||||
|
);
|
||||||
|
|
||||||
|
bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, 'ping', 3));
|
||||||
|
|
||||||
|
await waitFor(() => notifyMock.mock.calls.length === 1);
|
||||||
|
expect(svc.forwarded()).toEqual([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
from: KAMIL_NUM,
|
||||||
|
text: 'ping',
|
||||||
|
channel: 3,
|
||||||
|
at: expect.any(Number),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
delete process.env.MESH_NOTIFY_CHANNEL;
|
||||||
|
svc.onModuleDestroy();
|
||||||
|
bridge.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not record on channel mismatch', async () => {
|
||||||
|
const bridge = await startBridge();
|
||||||
|
process.env.MESH_HOST = '127.0.0.1';
|
||||||
|
process.env.MESH_PORT = String(bridge.port);
|
||||||
|
delete process.env.MESH_NOTIFY_CHANNEL; // default 3
|
||||||
|
const svc = makeSvc();
|
||||||
|
try {
|
||||||
|
svc.onModuleInit();
|
||||||
|
await waitFor(
|
||||||
|
() => bridge.sockets.length === 1 && svc.status().connected,
|
||||||
|
);
|
||||||
|
|
||||||
|
bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, 'ping', 2));
|
||||||
|
|
||||||
|
// the message is captured in history — but off-channel it never reaches the ring
|
||||||
|
await waitFor(() => svc.messages().length === 1);
|
||||||
|
expect(notifyMock).not.toHaveBeenCalled();
|
||||||
|
expect(svc.forwarded()).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
delete process.env.MESH_NOTIFY_CHANNEL;
|
||||||
|
svc.onModuleDestroy();
|
||||||
|
bridge.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not record a self-echo (rx 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);
|
||||||
|
delete process.env.MESH_NOTIFY_CHANNEL; // default 3
|
||||||
|
const svc = makeSvc();
|
||||||
|
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);
|
||||||
|
|
||||||
|
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(notifyMock).not.toHaveBeenCalled();
|
||||||
|
expect(svc.forwarded()).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
delete process.env.MESH_NOTIFY_CHANNEL;
|
||||||
|
svc.onModuleDestroy();
|
||||||
|
bridge.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not record when no notifier is wired', () => {
|
||||||
|
process.env.MESH_NOTIFY_CHANNEL = '0'; // injected records ride channel 0 — the gate would pass
|
||||||
|
const svc = new MeshService(); // unwired — pinned semantics: nothing forwarded, nothing recorded
|
||||||
|
try {
|
||||||
|
svc.injectMessage('hi');
|
||||||
|
|
||||||
|
expect(svc.forwarded()).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
delete process.env.MESH_NOTIFY_CHANNEL;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts injected messages that pass the gate and omits `from` when there is no sender', () => {
|
||||||
|
process.env.MESH_NOTIFY_CHANNEL = '0';
|
||||||
|
const svc = makeSvc();
|
||||||
|
try {
|
||||||
|
const r = svc.injectMessage('hi'); // no from
|
||||||
|
|
||||||
|
expect(svc.forwarded()).toEqual([
|
||||||
|
{ id: r.id, text: 'hi', channel: 0, at: expect.any(Number) },
|
||||||
|
]);
|
||||||
|
expect('from' in svc.forwarded()[0]!).toBe(false);
|
||||||
|
} finally {
|
||||||
|
delete process.env.MESH_NOTIFY_CHANNEL;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps at 100 (oldest evicted), newest first', () => {
|
||||||
|
process.env.MESH_NOTIFY_CHANNEL = '0';
|
||||||
|
const svc = makeSvc();
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < 101; i++) svc.injectMessage(`m-${i}`);
|
||||||
|
|
||||||
|
expect(svc.forwarded()).toHaveLength(100);
|
||||||
|
expect(svc.forwarded()[0]!.text).toBe('m-100'); // newest first
|
||||||
|
expect(svc.forwarded()[99]!.text).toBe('m-1'); // m-0 evicted
|
||||||
|
} finally {
|
||||||
|
delete process.env.MESH_NOTIFY_CHANNEL;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('MeshService injectMessage', () => {
|
describe('MeshService injectMessage', () => {
|
||||||
it('injects rx records that appear newest-first in messages()', () => {
|
it('injects rx records that appear newest-first in messages()', () => {
|
||||||
const svc = new MeshService();
|
const svc = new MeshService();
|
||||||
@@ -1318,6 +1671,44 @@ describe('MeshService SQLite persistence', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('persists the destination (to) of captured rx packets on poller tick', async () => {
|
||||||
|
const bridge = await startBridge();
|
||||||
|
process.env.MESH_HOST = '127.0.0.1';
|
||||||
|
process.env.MESH_PORT = String(bridge.port);
|
||||||
|
process.env.MESH_POLL_MS = '50';
|
||||||
|
const db = new DatabaseSync(':memory:');
|
||||||
|
initMessagesTable(db);
|
||||||
|
const svc = new MeshService(db);
|
||||||
|
try {
|
||||||
|
svc.onModuleInit();
|
||||||
|
await waitFor(
|
||||||
|
() => bridge.sockets.length === 1 && svc.status().connected,
|
||||||
|
);
|
||||||
|
|
||||||
|
bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, 'ping', 3));
|
||||||
|
|
||||||
|
// the poller tick must pick it up — no flushHistory() call here
|
||||||
|
await waitFor(
|
||||||
|
() =>
|
||||||
|
(
|
||||||
|
db.prepare('SELECT COUNT(*) AS n FROM messages').get() as {
|
||||||
|
n: number;
|
||||||
|
}
|
||||||
|
).n === 1,
|
||||||
|
);
|
||||||
|
const row = db
|
||||||
|
.prepare('SELECT * FROM messages WHERE id = ?')
|
||||||
|
.get(1) as Record<string, unknown>;
|
||||||
|
expect(row.dir).toBe('rx');
|
||||||
|
expect(row.from_node).toBe(KAMIL_NUM);
|
||||||
|
expect(row.to_node).toBe(42); // rx rows carry the destination now (no schema change — to_node existed for tx)
|
||||||
|
} finally {
|
||||||
|
delete process.env.MESH_POLL_MS;
|
||||||
|
svc.onModuleDestroy();
|
||||||
|
bridge.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('reflects the ACK status update in the database after the next tick', async () => {
|
it('reflects the ACK status update in the database after the next tick', async () => {
|
||||||
const bridge = await startBridge();
|
const bridge = await startBridge();
|
||||||
process.env.MESH_HOST = '127.0.0.1';
|
process.env.MESH_HOST = '127.0.0.1';
|
||||||
|
|||||||
+109
-21
@@ -1,6 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
Injectable,
|
Injectable,
|
||||||
Logger,
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
OnModuleDestroy,
|
OnModuleDestroy,
|
||||||
OnModuleInit,
|
OnModuleInit,
|
||||||
ServiceUnavailableException,
|
ServiceUnavailableException,
|
||||||
@@ -37,7 +39,7 @@ export interface MessageRecord {
|
|||||||
dir: 'rx' | 'tx';
|
dir: 'rx' | 'tx';
|
||||||
/** sender node num — rx only */
|
/** sender node num — rx only */
|
||||||
from?: number;
|
from?: number;
|
||||||
/** destination node num (broadcast addr for broadcasts) — tx only */
|
/** destination node num (broadcast addr for broadcasts) — captured rx packets and all tx records */
|
||||||
to?: number;
|
to?: number;
|
||||||
text: string;
|
text: string;
|
||||||
channel: number;
|
channel: number;
|
||||||
@@ -49,6 +51,18 @@ export interface MessageRecord {
|
|||||||
ackError?: string;
|
ackError?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One entry in the forwarded ring: an rx text that passed the notify gate and was handed off to opencode. */
|
||||||
|
export interface ForwardedRecord {
|
||||||
|
/** packet id (real rx) or generated record id (injected) */
|
||||||
|
id: number;
|
||||||
|
/** sender node num — omitted when there is no sender */
|
||||||
|
from?: number;
|
||||||
|
text: string;
|
||||||
|
channel: number;
|
||||||
|
/** epoch ms */
|
||||||
|
at: number;
|
||||||
|
}
|
||||||
|
|
||||||
/** One entry in the telemetry history: a TELEMETRY_APP packet from some node. */
|
/** One entry in the telemetry history: a TELEMETRY_APP packet from some node. */
|
||||||
export interface TelemetryRecord {
|
export interface TelemetryRecord {
|
||||||
/** sender node num */
|
/** sender node num */
|
||||||
@@ -84,6 +98,22 @@ export function initMessagesTable(db: DatabaseSync): void {
|
|||||||
db.exec(MESSAGES_DDL);
|
db.exec(MESSAGES_DDL);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Map a messages-table row to a MessageRecord (NULLs → omitted optionals). */
|
||||||
|
function recordFromRow(row: Record<string, unknown>): MessageRecord {
|
||||||
|
const rec: MessageRecord = {
|
||||||
|
id: Number(row.id),
|
||||||
|
dir: row.dir === 'tx' ? 'tx' : 'rx',
|
||||||
|
text: String(row.text),
|
||||||
|
channel: Number(row.channel),
|
||||||
|
at: Number(row.at),
|
||||||
|
};
|
||||||
|
if (row.to_node != null) rec.to = Number(row.to_node);
|
||||||
|
if (row.from_node != null) rec.from = Number(row.from_node);
|
||||||
|
if (row.status != null) rec.status = row.status as MessageRecord['status'];
|
||||||
|
if (row.ack_error != null) rec.ackError = String(row.ack_error);
|
||||||
|
return rec;
|
||||||
|
}
|
||||||
|
|
||||||
/** What we know about a node so far: full NodeInfo after sync, possibly just {num}+position for live-only nodes. */
|
/** What we know about a node so far: full NodeInfo after sync, possibly just {num}+position for live-only nodes. */
|
||||||
type NodeEntry = Pick<Proto.Mesh.NodeInfo, 'num'> &
|
type NodeEntry = Pick<Proto.Mesh.NodeInfo, 'num'> &
|
||||||
Partial<Omit<Proto.Mesh.NodeInfo, 'num'>>;
|
Partial<Omit<Proto.Mesh.NodeInfo, 'num'>>;
|
||||||
@@ -197,6 +227,8 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
|||||||
private moduleConfigSections = new Map<string, Record<string, unknown>>();
|
private moduleConfigSections = new Map<string, Record<string, unknown>>();
|
||||||
// message history (newest kept, capped at HISTORY_LIMIT) + pending tx records awaiting their routing ACK/NACK
|
// message history (newest kept, capped at HISTORY_LIMIT) + pending tx records awaiting their routing ACK/NACK
|
||||||
private history: MessageRecord[] = [];
|
private history: MessageRecord[] = [];
|
||||||
|
// rx texts handed off to the opencode notifier (newest first, capped at HISTORY_LIMIT); in-memory only
|
||||||
|
private forwardedHistory: ForwardedRecord[] = [];
|
||||||
private pendingTx = new Map<number, MessageRecord>();
|
private pendingTx = new Map<number, MessageRecord>();
|
||||||
// telemetry history (newest kept, capped at HISTORY_LIMIT)
|
// telemetry history (newest kept, capped at HISTORY_LIMIT)
|
||||||
private telemetryHistory: TelemetryRecord[] = [];
|
private telemetryHistory: TelemetryRecord[] = [];
|
||||||
@@ -358,6 +390,14 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
|||||||
return [...this.history].reverse();
|
return [...this.history].reverse();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The last HISTORY_LIMIT rx texts that passed the notify gate and were handed off to opencode, newest first:
|
||||||
|
* channel matched MESH_NOTIFY_CHANNEL, not a self-echo, and a notifier is wired. In-memory only — reset on restart.
|
||||||
|
*/
|
||||||
|
forwarded(): ForwardedRecord[] {
|
||||||
|
return [...this.forwardedHistory];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Re-upsert the entire in-memory history buffer into SQLite (INSERT OR REPLACE per row) and enforce
|
* Re-upsert the entire in-memory history buffer into SQLite (INSERT OR REPLACE per row) and enforce
|
||||||
* retention: only the newest DB_RETENTION rows survive. Called by the poller every MESH_POLL_MS;
|
* retention: only the newest DB_RETENTION rows survive. Called by the poller every MESH_POLL_MS;
|
||||||
@@ -393,21 +433,55 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
|||||||
'SELECT id, dir, to_node, from_node, text, channel, at, status, ack_error FROM messages ORDER BY at DESC LIMIT ?',
|
'SELECT id, dir, to_node, from_node, text, channel, at, status, ack_error FROM messages ORDER BY at DESC LIMIT ?',
|
||||||
).all(HISTORY_LIMIT) as Array<Record<string, unknown>>;
|
).all(HISTORY_LIMIT) as Array<Record<string, unknown>>;
|
||||||
// rows come back newest first — the buffer is oldest-first
|
// rows come back newest first — the buffer is oldest-first
|
||||||
for (const row of [...rows].reverse()) {
|
for (const row of [...rows].reverse())
|
||||||
const rec: MessageRecord = {
|
this.history.push(recordFromRow(row));
|
||||||
id: Number(row.id),
|
}
|
||||||
dir: row.dir === 'tx' ? 'tx' : 'rx',
|
|
||||||
text: String(row.text),
|
/** Look up a history record by packet id: in-memory buffer first, then SQLite (newest 1000). */
|
||||||
channel: Number(row.channel),
|
private findRecord(id: number): MessageRecord | undefined {
|
||||||
at: Number(row.at),
|
const hit = this.history.find((r) => r.id === id);
|
||||||
};
|
if (hit) return hit;
|
||||||
if (row.to_node != null) rec.to = Number(row.to_node);
|
const db = this.db;
|
||||||
if (row.from_node != null) rec.from = Number(row.from_node);
|
if (!db) return undefined;
|
||||||
if (row.status != null)
|
const row = db
|
||||||
rec.status = row.status as MessageRecord['status'];
|
.prepare(
|
||||||
if (row.ack_error != null) rec.ackError = String(row.ack_error);
|
'SELECT id, dir, to_node, from_node, text, channel, at, status, ack_error FROM messages WHERE id = ?',
|
||||||
this.history.push(rec);
|
)
|
||||||
}
|
.get(id) as Record<string, unknown> | undefined;
|
||||||
|
return row ? recordFromRow(row) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reply to an earlier message by packet id — the caller never thinks about where it goes:
|
||||||
|
* rx records are answered as a DM back to the original sender, tx records go to their destination;
|
||||||
|
* broadcast originals (and injected messages without a sender) get a broadcast. The reply always
|
||||||
|
* rides the record's channel and carries replyId = messageId, so the recipient app renders it as a
|
||||||
|
* threaded quote of that packet. Returns { id } with the new packet id. Throws BadRequest on invalid
|
||||||
|
* arguments, NotFound when no message has the given id (in memory or in SQLite), 503 when the bridge is down.
|
||||||
|
*/
|
||||||
|
async respond(messageId: number, content: string): Promise<{ id: number }> {
|
||||||
|
if (
|
||||||
|
!Number.isInteger(messageId) ||
|
||||||
|
messageId < 1 ||
|
||||||
|
messageId > BROADCAST_ADDR
|
||||||
|
)
|
||||||
|
throw new BadRequestException(
|
||||||
|
'messageId must be an integer in 1..0xffffffff',
|
||||||
|
);
|
||||||
|
if (typeof content !== 'string' || content.length === 0)
|
||||||
|
throw new BadRequestException('content must be a non-empty string');
|
||||||
|
const rec = this.findRecord(messageId);
|
||||||
|
if (!rec) throw new NotFoundException(`no message with id ${messageId}`);
|
||||||
|
// rx → DM back to the sender unless the original was a broadcast (then reply as a broadcast — that's what the stored destination is for);
|
||||||
|
// tx → its own destination; injected records without a sender → broadcast
|
||||||
|
const target =
|
||||||
|
rec.dir === 'rx'
|
||||||
|
? rec.to === BROADCAST_ADDR
|
||||||
|
? BROADCAST_ADDR
|
||||||
|
: rec.from
|
||||||
|
: rec.to;
|
||||||
|
const to = target ?? BROADCAST_ADDR;
|
||||||
|
return { id: await this.sendText(to, content, messageId, rec.channel) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -433,19 +507,32 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
|||||||
};
|
};
|
||||||
this.pushHistory(rec);
|
this.pushHistory(rec);
|
||||||
// injected records ride channel 0 — the notifier fires only when MESH_NOTIFY_CHANNEL is set to match
|
// injected records ride channel 0 — the notifier fires only when MESH_NOTIFY_CHANNEL is set to match
|
||||||
this.notifyRx(0, text, from);
|
this.notifyRx(0, text, from, rec.id);
|
||||||
return { id: rec.id };
|
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). */
|
/** 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. */
|
||||||
private notifyRx(channel: number, text: string, from?: number): void {
|
private notifyRx(
|
||||||
|
channel: number,
|
||||||
|
text: string,
|
||||||
|
from?: number,
|
||||||
|
id?: number,
|
||||||
|
): void {
|
||||||
if (!this.opencode || channel !== this.notifyChannel) return;
|
if (!this.opencode || channel !== this.notifyChannel) return;
|
||||||
// self-echo filter: our own node must never trigger a notification of itself
|
// self-echo filter: our own node must never trigger a notification of itself
|
||||||
if (from !== undefined && from === this.myNodeNum) {
|
if (from !== undefined && from === this.myNodeNum) {
|
||||||
this.logger.debug(`notify skipped: rx from our own node ${from}`);
|
this.logger.debug(`notify skipped: rx from our own node ${from}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void this.opencode.notify(text, from);
|
// record the forward at hand-off — the ring holds exactly what a wired notifier receives (newest first).
|
||||||
|
// Pinned semantics: an unwired notifier records nothing (early return above); a notifier disabled internally
|
||||||
|
// (empty credentials) still counts — MeshService cannot see its enabled flag.
|
||||||
|
const entry: ForwardedRecord = { id: id!, text, channel, at: Date.now() };
|
||||||
|
if (from !== undefined) entry.from = from;
|
||||||
|
this.forwardedHistory.unshift(entry);
|
||||||
|
if (this.forwardedHistory.length > HISTORY_LIMIT)
|
||||||
|
this.forwardedHistory.pop(); // oldest evicted
|
||||||
|
void this.opencode.notify(id!, text, from); // both call sites always pass the packet id
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -626,11 +713,12 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
|||||||
id: pkt.id,
|
id: pkt.id,
|
||||||
dir: 'rx',
|
dir: 'rx',
|
||||||
from: pkt.from,
|
from: pkt.from,
|
||||||
|
to: pkt.to, // destination (broadcast addr for broadcasts) — lets a later reply tell DM vs broadcast apart
|
||||||
text,
|
text,
|
||||||
channel: pkt.channel ?? 0,
|
channel: pkt.channel ?? 0,
|
||||||
at: Date.now(),
|
at: Date.now(),
|
||||||
});
|
});
|
||||||
this.notifyRx(pkt.channel ?? 0, text, pkt.from);
|
this.notifyRx(pkt.channel ?? 0, text, pkt.from, pkt.id);
|
||||||
} else if (decoded && decoded.portnum === PORT_POSITION_APP) {
|
} else if (decoded && decoded.portnum === PORT_POSITION_APP) {
|
||||||
// live position update: the Position is serialized in Data.payload
|
// live position update: the Position is serialized in Data.payload
|
||||||
let pos: Proto.Mesh.Position | undefined;
|
let pos: Proto.Mesh.Position | undefined;
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ describe('OpencodeService.notify', () => {
|
|||||||
.mockResolvedValueOnce(json([WORKSPACE]))
|
.mockResolvedValueOnce(json([WORKSPACE]))
|
||||||
.mockResolvedValueOnce(json({}));
|
.mockResolvedValueOnce(json({}));
|
||||||
|
|
||||||
await svc.notify('hi', 42);
|
await svc.notify(7, 'hi', 42);
|
||||||
|
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
const [url, init] = fetchMock.mock.calls[1];
|
const [url, init] = fetchMock.mock.calls[1];
|
||||||
@@ -89,7 +89,7 @@ describe('OpencodeService.notify', () => {
|
|||||||
parts: [
|
parts: [
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
text: 'New mesh message from node 42: "hi" — please react to it.',
|
text: 'New mesh message (id 7) from node 42: "hi" — please react to it.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
@@ -106,7 +106,7 @@ describe('OpencodeService.notify', () => {
|
|||||||
.mockResolvedValueOnce(json([WORKSPACE]))
|
.mockResolvedValueOnce(json([WORKSPACE]))
|
||||||
.mockResolvedValueOnce(json({}));
|
.mockResolvedValueOnce(json({}));
|
||||||
|
|
||||||
await svc.notify('hi');
|
await svc.notify(7, 'hi');
|
||||||
|
|
||||||
const [, init] = fetchMock.mock.calls[1];
|
const [, init] = fetchMock.mock.calls[1];
|
||||||
expect(String((init as { body?: string }).body)).toBe(
|
expect(String((init as { body?: string }).body)).toBe(
|
||||||
@@ -114,7 +114,7 @@ describe('OpencodeService.notify', () => {
|
|||||||
parts: [
|
parts: [
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
text: 'New mesh message: "hi" — please react to it.',
|
text: 'New mesh message (id 7): "hi" — please react to it.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
@@ -146,7 +146,7 @@ describe('OpencodeService.notify', () => {
|
|||||||
);
|
);
|
||||||
fetchMock.mockResolvedValueOnce(json({}));
|
fetchMock.mockResolvedValueOnce(json({}));
|
||||||
|
|
||||||
await svc.notify('hi');
|
await svc.notify(7, 'hi');
|
||||||
|
|
||||||
expect(fetchMock.mock.calls[1][0]).toBe(
|
expect(fetchMock.mock.calls[1][0]).toBe(
|
||||||
`${API}/proxy/abc/session/ses_pinned/message`,
|
`${API}/proxy/abc/session/ses_pinned/message`,
|
||||||
@@ -179,7 +179,7 @@ describe('OpencodeService.notify', () => {
|
|||||||
);
|
);
|
||||||
fetchMock.mockResolvedValueOnce(json({}));
|
fetchMock.mockResolvedValueOnce(json({}));
|
||||||
|
|
||||||
await svc.notify('hi');
|
await svc.notify(7, 'hi');
|
||||||
|
|
||||||
expect(fetchMock.mock.calls[1][0]).toBe(
|
expect(fetchMock.mock.calls[1][0]).toBe(
|
||||||
`${API}/proxy/b/session/ses_pinned/message`,
|
`${API}/proxy/b/session/ses_pinned/message`,
|
||||||
@@ -198,7 +198,7 @@ describe('OpencodeService.notify', () => {
|
|||||||
)
|
)
|
||||||
.mockResolvedValueOnce(json({}));
|
.mockResolvedValueOnce(json({}));
|
||||||
|
|
||||||
await svc.notify('hi');
|
await svc.notify(7, 'hi');
|
||||||
|
|
||||||
expect(fetchMock.mock.calls[1][0]).toBe(`${API}/proxy/abc/session`);
|
expect(fetchMock.mock.calls[1][0]).toBe(`${API}/proxy/abc/session`);
|
||||||
expect(fetchMock.mock.calls[2][0]).toBe(
|
expect(fetchMock.mock.calls[2][0]).toBe(
|
||||||
@@ -213,7 +213,7 @@ describe('OpencodeService.notify', () => {
|
|||||||
.mockResolvedValueOnce(json([{ id: 'ses_a' }, { id: 'ses_b', time: {} }]))
|
.mockResolvedValueOnce(json([{ id: 'ses_a' }, { id: 'ses_b', time: {} }]))
|
||||||
.mockResolvedValueOnce(json({}));
|
.mockResolvedValueOnce(json({}));
|
||||||
|
|
||||||
await svc.notify('hi');
|
await svc.notify(7, 'hi');
|
||||||
|
|
||||||
expect(fetchMock.mock.calls[2][0]).toBe(
|
expect(fetchMock.mock.calls[2][0]).toBe(
|
||||||
`${API}/proxy/abc/session/ses_a/message`,
|
`${API}/proxy/abc/session/ses_a/message`,
|
||||||
@@ -230,7 +230,7 @@ describe('OpencodeService.notify', () => {
|
|||||||
.mockResolvedValueOnce(json([WORKSPACE]))
|
.mockResolvedValueOnce(json([WORKSPACE]))
|
||||||
.mockResolvedValueOnce(json({}));
|
.mockResolvedValueOnce(json({}));
|
||||||
|
|
||||||
await svc.notify('hi');
|
await svc.notify(7, 'hi');
|
||||||
|
|
||||||
// workspaces GET + message POST only — no /session discovery GET
|
// workspaces GET + message POST only — no /session discovery GET
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
@@ -309,7 +309,7 @@ describe('OpencodeService.notify', () => {
|
|||||||
string
|
string
|
||||||
>[]) {
|
>[]) {
|
||||||
const svc = makeService(env);
|
const svc = makeService(env);
|
||||||
await expect(svc.notify('hi', 42)).resolves.toBeUndefined();
|
await expect(svc.notify(7, 'hi', 42)).resolves.toBeUndefined();
|
||||||
expect(fetchMock).not.toHaveBeenCalled();
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -49,10 +49,10 @@ export class OpencodeService {
|
|||||||
* up to RETRY_DELAYS_MS.length + 1 attempts total; the last failure logs `opencode notify dropped
|
* up to RETRY_DELAYS_MS.length + 1 attempts total; the last failure logs `opencode notify dropped
|
||||||
* after N attempts`. No-op when disabled (no credentials).
|
* after N attempts`. No-op when disabled (no credentials).
|
||||||
*/
|
*/
|
||||||
async notify(text: string, from?: number): Promise<void> {
|
async notify(id: number, text: string, from?: number): Promise<void> {
|
||||||
if (!this.enabled) return;
|
if (!this.enabled) return;
|
||||||
const msg =
|
const msg =
|
||||||
'New mesh message' +
|
`New mesh message (id ${id})` +
|
||||||
(from != null ? ` from node ${from}` : '') +
|
(from != null ? ` from node ${from}` : '') +
|
||||||
`: "${text}" — please react to it.`;
|
`: "${text}" — please react to it.`;
|
||||||
const maxAttempts = RETRY_DELAYS_MS.length + 1;
|
const maxAttempts = RETRY_DELAYS_MS.length + 1;
|
||||||
|
|||||||
Reference in New Issue
Block a user