Optional replyId on send (API + MCP) — protocol-level threaded replies (#31)
CI / build + unit (push) Successful in 33s

This commit was merged in pull request #31.
This commit is contained in:
2026-09-25 13:53:47 +02:00
parent 0909fbc6e2
commit 15d93ec2a4
11 changed files with 137 additions and 15 deletions
+25 -1
View File
@@ -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);
});
});
+3
View File
@@ -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,
},
},
});
+1
View File
@@ -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')!;
+10 -4
View File
@@ -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(
+19 -2
View File
@@ -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')
+58
View File
@@ -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', () => {
+8 -3
View File
@@ -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<number> {
/** 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<number> {
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',