diff --git a/AGENTS.md b/AGENTS.md index e244503..0f1a3a1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,8 +17,8 @@ ## API 1. **Live base URL** — `http://bot.dev.kkhost.pl` (Traefik → docker-compose stack on this host; container port 3000 via external network dev_net, no published ports). -2. **Endpoints** — all under the `/api` global prefix: `GET /api/status` (`{ connected, myNodeNum, host, port }`), `POST /api/send` (body `{ "text": "...", "to"?: }` — numbers pass through as-is (integer 1..0xffffffff), names resolve like `GET /api/nodes/:id`, omitted/""/"!b"/"broadcast" → broadcast; unknown or ambiguous targets are a 400. Returns `{ id }`), `GET /api/channels` (the device's Meshtastic channels as `{ index, name, uplinkEnabled, downlinkEnabled }[]`; psk/keys redacted), `GET /api/device` (device info captured during the initial sync: `{ myNodeNum, firmwareVersion?, metadata?, localConfig: {
: ...}, moduleConfig: {: ...} }` — DeviceMetadata as-is plus per-section Config/ModuleConfig messages; security publicKey/privateKey/adminKey and network wifiPsk redacted to `"REDACTED"`; in-memory, reset on reconnect), `GET /api/messages` (message history newest first: rx text with `from`, tx sends with `to` + `status` pending→delivered/failed via routing ACKs — broadcasts stay pending because firmware strips want_ack; last 100 in memory (seeded from SQLite on startup) — backed by SQLite: re-upserted every MESH_POLL_MS and pruned to the newest 1000 rows, so it survives process restarts and bridge reconnects), `POST /api/messages` (body `{ "text": "...", "from"?: }`, 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, nothing goes to the bridge or air; appears in `GET /api/messages` like any other rx message), `GET /api/telemetry` (telemetry history newest first as `{ from, at /* epoch ms */, kind, data }[]`: live TELEMETRY_APP packets — device metrics and environment sensor readings where nodes have them; kind is the meshtastic.Telemetry oneof case name (deviceMetrics/environmentMetrics/airQualityMetrics/powerMetrics/localStats/healthMetrics/hostMetrics/trafficManagementStats), data that variant's payload with proto uint64 fields as decimal strings; last 100, in-memory, reset on reconnect), `GET /api/nodes` (known nodes sorted by num as `{ num, longName?, shortName?, hwModel?, batteryLevel?, lastHeard: number|null, position?: { lat, lon, alt? }, snr?, hopsAway?, viaMqtt? }[]`, from the initial sync plus live POSITION_APP updates; reset on reconnect) and `GET /api/nodes/:id` (lookup by exact node number/numeric string, then case-insensitive exact long/short name, else unique substring; 404 when ambiguous or missing). Scalar API docs are served at `/` (OpenAPI JSON at `/docs-json`). Keep this list in sync with the controller. All `POST /api/send` traffic targets channel index 3 (`test`) on purpose — never the primary channel; override via `MESH_SEND_CHANNEL` (default `3`). Message history is persisted to SQLite: file path from `MESH_DB_PATH` (default `./data/mesh-bot.db`, parent dir auto-created), poller re-upserts every `MESH_POLL_MS` ms (default 1000) and prunes the table to the newest 1000 rows. Outbound notification webhook: when an RX text arrives on channel `MESH_NOTIFY_CHANNEL` (default `3`), a fire-and-forget HTTP POST of `{text, from}` (`from` omitted when absent) goes to `MESH_NOTIFY_URL` — unset/empty disables it entirely; failed POSTs are retried up to 3 times with backoff (2 s / 10 s / 30 s) before being dropped; no response handling. Injected messages (`POST /api/messages`, channel 0) trigger it too when the notify channel is set to match. Messages from our own node are never notified (self-echo filter). The forwarder resolves the mesh-bot workspace/session dynamically from the opencode API per request (`FORWARDER_API`/`FORWARDER_WORKSPACE_PATH`; `FORWARDER_SESSION` pins a session when set). -3. **MCP endpoint** — `POST /mcp` (excluded from the `/api` prefix in main.ts, like Scalar at `/`): an in-process MCP server over the official SDK's Streamable HTTP transport in stateless mode (`sessionIdGenerator: undefined`, fresh `McpServer` + transport per request). Tools map 1:1 to MeshService methods: `mesh_status`, `send_message(text, to?)` (same resolution as `POST /api/send`; errors surface as tool errors with the same messages), `list_channels`, `list_nodes`, `get_node(id)` (tool error when missing/ambiguous), `get_messages`, `inject_message(text, from?)` (synthetic inbox injection for external triggers — records an rx entry in the history as if received on the mesh; NOT sent over RF; same validation as `POST /api/messages`), `get_telemetry` (same bounded recent window as `GET /api/telemetry`), `device_info` (same redaction as `GET /api/device`). Results are JSON in a text content block; sends ride channel index 3 like everything else. Note: the SDK's root entry is broken upstream (#2273) — import via subpaths (`@modelcontextprotocol/sdk/server/mcp.js` etc.). +2. **Endpoints** — all under the `/api` global prefix: `GET /api/status` (`{ connected, myNodeNum, host, port }`), `POST /api/send` (body `{ "text": "...", "to"?: , "replyId"?: }` — numbers pass through as-is (integer 1..0xffffffff), names resolve like `GET /api/nodes/:id`, omitted/""/"!b"/"broadcast" → broadcast; unknown or ambiguous targets are a 400. Optional `replyId` (integer in 1..0xffffffff, else 400) 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 (`Data.reply_id`, field 7). Returns `{ id }`), `GET /api/channels` (the device's Meshtastic channels as `{ index, name, uplinkEnabled, downlinkEnabled }[]`; psk/keys redacted), `GET /api/device` (device info captured during the initial sync: `{ myNodeNum, firmwareVersion?, metadata?, localConfig: {
: ...}, moduleConfig: {: ...} }` — DeviceMetadata as-is plus per-section Config/ModuleConfig messages; security publicKey/privateKey/adminKey and network wifiPsk redacted to `"REDACTED"`; in-memory, reset on reconnect), `GET /api/messages` (message history newest first: rx text with `from`, tx sends with `to` + `status` pending→delivered/failed via routing ACKs — broadcasts stay pending because firmware strips want_ack; last 100 in memory (seeded from SQLite on startup) — backed by SQLite: re-upserted every MESH_POLL_MS and pruned to the newest 1000 rows, so it survives process restarts and bridge reconnects), `POST /api/messages` (body `{ "text": "...", "from"?: }`, 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, nothing goes to the bridge or air; appears in `GET /api/messages` like any other rx message), `GET /api/telemetry` (telemetry history newest first as `{ from, at /* epoch ms */, kind, data }[]`: live TELEMETRY_APP packets — device metrics and environment sensor readings where nodes have them; kind is the meshtastic.Telemetry oneof case name (deviceMetrics/environmentMetrics/airQualityMetrics/powerMetrics/localStats/healthMetrics/hostMetrics/trafficManagementStats), data that variant's payload with proto uint64 fields as decimal strings; last 100, in-memory, reset on reconnect), `GET /api/nodes` (known nodes sorted by num as `{ num, longName?, shortName?, hwModel?, batteryLevel?, lastHeard: number|null, position?: { lat, lon, alt? }, snr?, hopsAway?, viaMqtt? }[]`, from the initial sync plus live POSITION_APP updates; reset on reconnect) and `GET /api/nodes/:id` (lookup by exact node number/numeric string, then case-insensitive exact long/short name, else unique substring; 404 when ambiguous or missing). Scalar API docs are served at `/` (OpenAPI JSON at `/docs-json`). Keep this list in sync with the controller. All `POST /api/send` traffic targets channel index 3 (`test`) on purpose — never the primary channel; override via `MESH_SEND_CHANNEL` (default `3`). Message history is persisted to SQLite: file path from `MESH_DB_PATH` (default `./data/mesh-bot.db`, parent dir auto-created), poller re-upserts every `MESH_POLL_MS` ms (default 1000) and prunes the table to the newest 1000 rows. Outbound notification webhook: when an RX text arrives on channel `MESH_NOTIFY_CHANNEL` (default `3`), a fire-and-forget HTTP POST of `{text, from}` (`from` omitted when absent) goes to `MESH_NOTIFY_URL` — unset/empty disables it entirely; failed POSTs are retried up to 3 times with backoff (2 s / 10 s / 30 s) before being dropped; no response handling. Injected messages (`POST /api/messages`, channel 0) trigger it too when the notify channel is set to match. Messages from our own node are never notified (self-echo filter). The forwarder resolves the mesh-bot workspace/session dynamically from the opencode API per request (`FORWARDER_API`/`FORWARDER_WORKSPACE_PATH`; `FORWARDER_SESSION` pins a session when set). +3. **MCP endpoint** — `POST /mcp` (excluded from the `/api` prefix in main.ts, like Scalar at `/`): an in-process MCP server over the official SDK's Streamable HTTP transport in stateless mode (`sessionIdGenerator: undefined`, fresh `McpServer` + transport per request). Tools map 1:1 to MeshService methods: `mesh_status`, `send_message(text, to?, replyId?)` (`to` resolves like `POST /api/send`; optional `replyId` integer in 1..0xffffffff makes the message render as a threaded reply/quote of that packet id from `GET /api/messages` or `get_messages`; errors surface as tool errors with the same messages), `list_channels`, `list_nodes`, `get_node(id)` (tool error when missing/ambiguous), `get_messages`, `inject_message(text, from?)` (synthetic inbox injection for external triggers — records an rx entry in the history as if received on the mesh; NOT sent over RF; same validation as `POST /api/messages`), `get_telemetry` (same bounded recent window as `GET /api/telemetry`), `device_info` (same redaction as `GET /api/device`). Results are JSON in a text content block; sends ride channel index 3 like everything else. Note: the SDK's root entry is broken upstream (#2273) — import via subpaths (`@modelcontextprotocol/sdk/server/mcp.js` etc.). ## Other diff --git a/CHANGELOG.md b/CHANGELOG.md index e1e2604..daf49cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## v0.12.7 — 2026-09-25 11:40 + +- `POST /api/send` and the MCP `send_message` tool now accept an optional `replyId` (integer in 1..0xffffffff) referencing an earlier message's packet id from `GET /api/messages` / `get_messages`. When set, the recipient app renders the sent text as a threaded reply/quote of that packet. The value rides Meshtastic's protocol-level reply mechanism: `Data.reply_id` (field 7) on the TEXT_MESSAGE_APP payload. +- `buildTextToRadio(to, text, channelIndex, replyId?)` in `src/mesh/codec.ts` gained the optional param and sets **camelCase** `replyId` on the Data value (`replyId ?? 0`). The JS protobufs package silently drops snake_case `reply_id`, so camelCase is mandatory; a byte-level spec asserts wire tag `0x3d` (field 7, fixed32) appears iff `replyId` was given. +- `MeshService.sendText(to, text, replyId?)` passes the value through to the codec. REST validation rejects non-integers and out-of-range values with a 400; MCP validates via zod (`z.number().int().min(1).max(0xffffffff)`). Omitting it leaves the field unset (proto3 default 0, not serialized) — existing callers are unaffected. +- New specs: codec byte-level `0x3d` presence/absence check and a service outbound-capture case asserting `Data.replyId === ` when given and `0` when omitted. All 62 pre-existing tests stay green (64 total). +- Docs: README endpoint + MCP tool lines, AGENTS.md API section, and the controller's `@ApiOperation` description now document the optional `replyId`. + ## v0.12.6 — 2026-09-25 10:55 - Docs (MESHTASTIC.md response protocol): reverted the v0.12.5 reply rule per owner preference ("we will talk on that channel") — replies to messages from a specific node now go back **on the same channel as a broadcast** (no `to`), no direct messages; broadcasts are used for both replies and general announcements such as the hourly weather report. The always-respond-first bullet (**ALWAYS respond ... BEFORE starting any work it triggers**) and every other rule are unchanged. No code changes — `POST /api/send` and the MCP send tool already accept an omitted `to` (broadcast). diff --git a/README.md b/README.md index bdf60a4..1c6e064 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Exposed via Traefik only (no published ports) at `http://bot.dev.kkhost.pl`. All API endpoints live under the `/api` prefix: - `GET /api/status` — `{ connected, myNodeNum, host, port }` -- `POST /api/send` — body `{ "text": "...", "to"?: }`, 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 +- `POST /api/send` — body `{ "text": "...", "to"?: , "replyId"?: }`, 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 - `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: {
: ...}, moduleConfig: {: ...} }`. 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 @@ -67,7 +67,7 @@ Streamable HTTP transport in stateless mode. It maps 1:1 to the REST endpoints — no new logic, same channel pinning: - `mesh_status` — bridge connection state (`{ connected, myNodeNum, host, port }`) -- `send_message(text, to?)` — send a text message; `to` resolves like `POST /api/send` (omitted/""/"!b"/"broadcast" → broadcast, number or numeric string → node number, else name lookup); 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 - `list_channels` — device channels (keys redacted) - `list_nodes` — known nodes as lean summaries - `get_node(id)` — one node by number or name; tool error when missing/ambiguous diff --git a/package.json b/package.json index 5c73e39..1798234 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mesh-bot", - "version": "0.12.6", + "version": "0.12.7", "description": "", "author": "", "private": true, diff --git a/src/mesh/codec.spec.ts b/src/mesh/codec.spec.ts index b9bf05d..d72e9d6 100644 --- a/src/mesh/codec.spec.ts +++ b/src/mesh/codec.spec.ts @@ -1,5 +1,7 @@ +import { fromBinary, toBinary } from '@bufbuild/protobuf'; +import * as P from '@meshtastic/protobufs'; import { describe, expect, it } from 'vitest'; -import { extractFrames, frame } from './codec.js'; +import { buildTextToRadio, extractFrames, frame } from './codec.js'; const F = (payload: number[]) => [ 0x94, @@ -78,3 +80,25 @@ describe('frame', () => { expect(rest.length).toBe(0); }); }); + +// re-serialize just the nested Data message so its wire bytes are inspectable without the random packet id interfering +function dataBytes(framed: Uint8Array): Uint8Array { + const { packets } = extractFrames(framed); // buildTextToRadio returns framed bytes + expect(packets).toHaveLength(1); + const tr = fromBinary(P.Mesh.ToRadioSchema, packets[0]); + if (tr.payloadVariant?.case !== 'packet') throw new Error('not a packet'); + const pv = tr.payloadVariant.value.payloadVariant; + if (pv?.case !== 'decoded') throw new Error('not decoded'); + return toBinary(P.Mesh.DataSchema, pv.value); +} + +describe('buildTextToRadio', () => { + it('writes wire tag 0x3d (Data.reply_id, field 7) iff replyId is given', async () => { + const withReply = await buildTextToRadio(42, 'hi', 3, 42); + expect(Array.from(dataBytes(withReply.bytes))).toContain(0x3d); + + // omitting it must leave the field off the wire (proto3 default 0) — snake_case reply_id would be silently dropped here + const plain = await buildTextToRadio(42, 'hi', 3); + expect(Array.from(dataBytes(plain.bytes))).not.toContain(0x3d); + }); +}); diff --git a/src/mesh/codec.ts b/src/mesh/codec.ts index 2918147..68f51e6 100644 --- a/src/mesh/codec.ts +++ b/src/mesh/codec.ts @@ -126,6 +126,7 @@ export async function buildTextToRadio( to: number, text: string, channelIndex: number, + replyId?: number, ): Promise<{ bytes: Uint8Array; id: number }> { const P = await loadProto(); const id = randomPacketId(); @@ -140,6 +141,8 @@ export async function buildTextToRadio( portnum: P.Portnums.PortNum.TEXT_MESSAGE_APP, payload: new TextEncoder().encode(text), wantResponse: true, + // camelCase on purpose — the JS protobufs package silently drops snake_case reply_id; 0 = not a reply (proto3 default, omitted from wire) + replyId: replyId ?? 0, }, }, }); diff --git a/src/mesh/mcp.controller.spec.ts b/src/mesh/mcp.controller.spec.ts index b1690ba..6e011b5 100644 --- a/src/mesh/mcp.controller.spec.ts +++ b/src/mesh/mcp.controller.spec.ts @@ -91,6 +91,7 @@ describe('MCP server tools', () => { expect(Object.keys(send.inputSchema.properties ?? {})).toEqual([ 'text', 'to', + 'replyId', ]); const inject = tools.find((t) => t.name === 'inject_message')!; diff --git a/src/mesh/mcp.controller.ts b/src/mesh/mcp.controller.ts index d110ebd..f940fb3 100644 --- a/src/mesh/mcp.controller.ts +++ b/src/mesh/mcp.controller.ts @@ -33,11 +33,17 @@ export function createMeshMcpServer(mesh: MeshService): McpServer { server.registerTool( 'send_message', { - description: `Send a text message over the mesh; returns { id } — the Meshtastic packet id. to: omitted/"" or "!b"/"broadcast" (case-insensitive) → broadcast; number or numeric string → that node number (integer in 1..0xffffffff); anything else is looked up by name (exact long/short name, then unique substring). Unknown or ambiguous targets are an error.${CHANNEL_NOTE}`, - inputSchema: { text: z.string(), to: z.string().optional() }, + description: `Send a text message over the mesh; returns { id } — the Meshtastic packet id. to: omitted/"" or "!b"/"broadcast" (case-insensitive) → broadcast; number or numeric string → that node number (integer in 1..0xffffffff); anything else is looked up by name (exact long/short name, then unique substring). Unknown or ambiguous targets are an error. replyId (optional, integer in 1..0xffffffff): the packet id of an earlier message from GET /api/messages or get_messages — when set, the recipient app renders this message as a threaded reply/quote of that packet.${CHANNEL_NOTE}`, + inputSchema: { + text: z.string(), + to: z.string().optional(), + replyId: z.number().int().min(1).max(0xffffffff).optional(), + }, }, - async ({ text, to }) => - jsonResult({ id: await mesh.sendText(await mesh.resolveDest(to), text) }), + async ({ text, to, replyId }) => + jsonResult({ + id: await mesh.sendText(await mesh.resolveDest(to), text, replyId), + }), ); server.registerTool( diff --git a/src/mesh/mesh.controller.ts b/src/mesh/mesh.controller.ts index eeb2410..41b0b65 100644 --- a/src/mesh/mesh.controller.ts +++ b/src/mesh/mesh.controller.ts @@ -13,6 +13,7 @@ import { ApiResponse, ApiTags, } from '@nestjs/swagger'; +import { BROADCAST_ADDR } from './codec.js'; import type { DeviceInfo } from './mesh.service.js'; import { MeshService } from './mesh.service.js'; @@ -38,6 +39,13 @@ export class SendTextDto { example: '!b', }) to?: string | number; + @ApiProperty({ + required: false, + description: + 'Packet id of an earlier message (from GET /api/messages) that this one replies to — the recipient app renders it as a threaded reply/quote. Integer in 1..0xffffffff.', + example: 456789, + }) + replyId?: number; } // tag description is set via DocumentBuilder.addTag in main.ts (v12 ApiTagOptions has no description) @@ -70,7 +78,7 @@ export class MeshController { @ApiOperation({ summary: 'Send a text message over the mesh', description: - 'Queues a TEXT_MESSAGE_APP packet to the resolved target (node number, node name, or broadcast). DMs are unicast and wantAck-tracked via GET /api/messages; broadcasts stay pending because firmware strips want_ack. All sends ride the pinned test channel (index 3, "test" — never the primary channel) regardless of DM or broadcast, since modern firmware PKI handles unicast encryption/channel anyway. Returns the Meshtastic packet id.', + 'Queues a TEXT_MESSAGE_APP packet to the resolved target (node number, node name, or broadcast). Optional replyId (integer in 1..0xffffffff) references an earlier message\'s packet id from GET /api/messages — when set, the recipient app renders this message as a threaded reply/quote of that packet. DMs are unicast and wantAck-tracked via GET /api/messages; broadcasts stay pending because firmware strips want_ack. All sends ride the pinned test channel (index 3, "test" — never the primary channel) regardless of DM or broadcast, since modern firmware PKI handles unicast encryption/channel anyway. Returns the Meshtastic packet id.', }) @ApiResponse({ status: 200, @@ -83,6 +91,15 @@ export class MeshController { if (typeof body?.text !== 'string' || body.text.length === 0) { throw new BadRequestException('text must be a non-empty string'); } + const replyId = body.replyId; + if ( + replyId !== undefined && + (!Number.isInteger(replyId) || replyId < 1 || replyId > BROADCAST_ADDR) + ) { + throw new BadRequestException( + 'replyId must be an integer in 1..0xffffffff', + ); + } let to: number; try { to = await this.mesh.resolveDest(body.to); @@ -91,7 +108,7 @@ export class MeshController { err instanceof Error ? err.message : String(err), ); } - return { id: await this.mesh.sendText(to, body.text) }; + return { id: await this.mesh.sendText(to, body.text, replyId) }; } @Get('channels') diff --git a/src/mesh/mesh.service.spec.ts b/src/mesh/mesh.service.spec.ts index ae8dbcd..cc0699a 100644 --- a/src/mesh/mesh.service.spec.ts +++ b/src/mesh/mesh.service.spec.ts @@ -296,6 +296,64 @@ describe('MeshService send', () => { bridge.close(); } }); + + it('sets Data.reply_id when replyId is given and leaves it unset otherwise', 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 out: Uint8Array[] = []; + bridge.sockets[0].on('data', (c) => out.push(c)); + await svc.sendText(42, 'reply send', 42); // replyId = 42 + await svc.sendText(43, 'plain send'); + + const readOutbound = (): 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; + } + const { packets } = extractFrames(merged); + // outbound starts with the want_config_id frame — collect only text packets, in order + return packets.flatMap((p) => { + const tr = fromBinary(P.Mesh.ToRadioSchema, p); + if (tr.payloadVariant?.case === 'packet') + return [tr.payloadVariant.value]; + return []; + }); + }; + + await waitFor(() => readOutbound().length === 2); + const [replyPkt, plainPkt] = readOutbound(); + const replyData = + replyPkt.payloadVariant?.case === 'decoded' + ? replyPkt.payloadVariant.value + : undefined; + expect( + new TextDecoder().decode(replyData?.payload ?? new Uint8Array()), + ).toBe('reply send'); + expect(replyData?.replyId).toBe(42); // threaded reply to packet 42 + + const plainData = + plainPkt.payloadVariant?.case === 'decoded' + ? plainPkt.payloadVariant.value + : undefined; + expect( + new TextDecoder().decode(plainData?.payload ?? new Uint8Array()), + ).toBe('plain send'); + expect(plainData?.replyId).toBe(0); // omitted → proto3 default, not a reply + } finally { + svc.onModuleDestroy(); + bridge.close(); + } + }); }); describe('MeshService node database', () => { diff --git a/src/mesh/mesh.service.ts b/src/mesh/mesh.service.ts index 36a4de8..3cfeba0 100644 --- a/src/mesh/mesh.service.ts +++ b/src/mesh/mesh.service.ts @@ -488,11 +488,16 @@ export class MeshService implements OnModuleInit, OnModuleDestroy { return [...this.telemetryHistory].reverse(); } - /** Send `text` to node `to` on the pinned test channel; returns the packet id. Throws ServiceUnavailable when the bridge is down. */ - async sendText(to: number, text: string): Promise { + /** Send `text` to node `to` on the pinned test channel; returns the packet id. Optional `replyId` marks the message as a reply to an earlier packet (the recipient app renders it as a threaded quote). Throws ServiceUnavailable when the bridge is down. */ + async sendText(to: number, text: string, replyId?: number): Promise { if (!this.connected || !this.socket) throw new ServiceUnavailableException('bridge not connected'); - const { bytes, id } = await buildTextToRadio(to, text, this.sendChannel); + const { bytes, id } = await buildTextToRadio( + to, + text, + this.sendChannel, + replyId, + ); const rec: MessageRecord = { id, dir: 'tx',