Inbox injection endpoint: POST /api/messages + MCP inject_message (#12) (#18)
CI / build + unit (push) Successful in 1m14s
CI / build + unit (push) Successful in 1m14s
This commit was merged in pull request #18.
This commit is contained in:
+8
-2
@@ -115,14 +115,20 @@ export async function loadHwModelNames(): Promise<Map<number, string>> {
|
||||
return hwModelNames;
|
||||
}
|
||||
|
||||
/** Random non-zero uint32 packet id. */
|
||||
export function randomPacketId(): number {
|
||||
let id = Math.floor(Math.random() * 0x100000000) >>> 0;
|
||||
if (id === 0) id = 1; // non-zero uint32
|
||||
return id;
|
||||
}
|
||||
|
||||
export async function buildTextToRadio(
|
||||
to: number,
|
||||
text: string,
|
||||
channelIndex: number,
|
||||
): Promise<{ bytes: Uint8Array; id: number }> {
|
||||
const P = await loadProto();
|
||||
let id = Math.floor(Math.random() * 0x100000000) >>> 0;
|
||||
if (id === 0) id = 1; // non-zero uint32
|
||||
const id = randomPacketId();
|
||||
const meshPacket = create(P.Mesh.MeshPacketSchema, {
|
||||
to,
|
||||
id,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { fromBinary } from '@bufbuild/protobuf';
|
||||
import * as P from '@meshtastic/protobufs';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
|
||||
import type * as net from 'node:net';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { BROADCAST_ADDR } from './codec.js';
|
||||
import { BROADCAST_ADDR, extractFrames } from './codec.js';
|
||||
import {
|
||||
KAMIL_NUM,
|
||||
kamilNodeInfo,
|
||||
@@ -65,7 +67,7 @@ async function connectMcp(svc: MeshService): Promise<{
|
||||
}
|
||||
|
||||
describe('MCP server tools', () => {
|
||||
it('lists all eight tools with descriptions and input schemas', async () => {
|
||||
it('lists all nine tools with descriptions and input schemas', async () => {
|
||||
const env = await connectedService();
|
||||
try {
|
||||
const mcp = await connectMcp(env.svc);
|
||||
@@ -76,6 +78,7 @@ describe('MCP server tools', () => {
|
||||
'get_messages',
|
||||
'get_node',
|
||||
'get_telemetry',
|
||||
'inject_message',
|
||||
'list_channels',
|
||||
'list_nodes',
|
||||
'mesh_status',
|
||||
@@ -89,6 +92,13 @@ describe('MCP server tools', () => {
|
||||
'text',
|
||||
'to',
|
||||
]);
|
||||
|
||||
const inject = tools.find((t) => t.name === 'inject_message')!;
|
||||
expect(inject.inputSchema.required).toEqual(['text']);
|
||||
expect(Object.keys(inject.inputSchema.properties ?? {})).toEqual([
|
||||
'text',
|
||||
'from',
|
||||
]);
|
||||
} finally {
|
||||
await mcp.close();
|
||||
}
|
||||
@@ -185,6 +195,68 @@ describe('MCP server tools', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('inject_message records an rx entry without sending over RF', async () => {
|
||||
const env = await connectedService();
|
||||
try {
|
||||
const mcp = await connectMcp(env.svc);
|
||||
try {
|
||||
// capture outbound bytes: the only frame ever written on connect is want_config,
|
||||
// so any 'packet' variant after this point would be an RF transmission
|
||||
const out: Uint8Array[] = [];
|
||||
env.sockets[0].on('data', (c) => out.push(c));
|
||||
|
||||
const r1 = await mcp.client.callTool({
|
||||
name: 'inject_message',
|
||||
arguments: { text: 'trigger' },
|
||||
});
|
||||
expect(r1.isError).not.toBe(true);
|
||||
const inj = JSON.parse(toolText(r1)) as { id: number };
|
||||
expect(inj.id).toBeGreaterThan(0);
|
||||
expect(env.svc.messages()[0]).toMatchObject({
|
||||
dir: 'rx',
|
||||
text: 'trigger',
|
||||
channel: 0,
|
||||
});
|
||||
|
||||
const r2 = await mcp.client.callTool({
|
||||
name: 'inject_message',
|
||||
arguments: { text: 'from kamil', from: KAMIL_NUM },
|
||||
});
|
||||
expect(r2.isError).not.toBe(true);
|
||||
expect(env.svc.messages()[0]).toMatchObject({
|
||||
dir: 'rx',
|
||||
from: KAMIL_NUM,
|
||||
text: 'from kamil',
|
||||
});
|
||||
|
||||
const r3 = await mcp.client.callTool({
|
||||
name: 'inject_message',
|
||||
arguments: { text: 'x', from: 0 },
|
||||
});
|
||||
expect(r3.isError).toBe(true);
|
||||
expect(toolText(r3)).toContain('invalid node number');
|
||||
|
||||
// nothing went over the wire — synthetic inbox injection only.
|
||||
// The want_config frame (written on connect) may or may not have been captured;
|
||||
// what matters is that no ToRadio 'packet' variant ever made it to the bridge.
|
||||
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;
|
||||
}
|
||||
for (const p of extractFrames(merged).packets) {
|
||||
const tr = fromBinary(P.Mesh.ToRadioSchema, p);
|
||||
expect(tr.payloadVariant?.case).not.toBe('packet');
|
||||
}
|
||||
} finally {
|
||||
await mcp.close();
|
||||
}
|
||||
} finally {
|
||||
env.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('get_node resolves by name and errors when missing', async () => {
|
||||
const env = await connectedService();
|
||||
try {
|
||||
|
||||
@@ -77,6 +77,15 @@ export function createMeshMcpServer(mesh: MeshService): McpServer {
|
||||
async () => jsonResult(mesh.messages()),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'inject_message',
|
||||
{
|
||||
description: `Synthetic inbox injection for external triggers: records an rx entry in the message history as if received on the mesh — NOT sent over RF, nothing goes to the bridge or air. Appears in get_messages like any other rx record (channel 0). from is optional (integer in 1..0xffffffff); empty text or invalid from are tool errors.${CHANNEL_NOTE}`,
|
||||
inputSchema: { text: z.string(), from: z.number().int().optional() },
|
||||
},
|
||||
async ({ text, from }) => jsonResult(mesh.injectMessage(text, from)),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'get_telemetry',
|
||||
{
|
||||
|
||||
@@ -16,6 +16,18 @@ import {
|
||||
import type { DeviceInfo } from './mesh.service.js';
|
||||
import { MeshService } from './mesh.service.js';
|
||||
|
||||
export class InjectMessageDto {
|
||||
@ApiProperty({ description: 'Message text', example: 'external trigger' })
|
||||
text!: string;
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
description:
|
||||
'Sender node number (integer in 1..0xffffffff); omitted when the external source has no mesh identity.',
|
||||
example: 987654321,
|
||||
})
|
||||
from?: number;
|
||||
}
|
||||
|
||||
export class SendTextDto {
|
||||
@ApiProperty({ description: 'Message text', example: 'hello' })
|
||||
text!: string;
|
||||
@@ -183,6 +195,28 @@ export class MeshController {
|
||||
return this.mesh.messages();
|
||||
}
|
||||
|
||||
@Post('messages')
|
||||
@ApiOperation({
|
||||
summary: 'Inject a synthetic inbound message into the history',
|
||||
description:
|
||||
'Synthetic inbox injection for external triggers: records an rx entry in the message history as if received on the mesh, so external systems can push messages to trigger communication later. NOT transmitted over RF — nothing is sent to the bridge or air. The record appears in GET /api/messages like any other rx message (dir "rx", channel 0). Returns { id } with a random uint32 packet id.',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Record injected',
|
||||
example: { id: 123456789 },
|
||||
})
|
||||
@ApiResponse({ status: 400, description: 'Invalid text or from' })
|
||||
injectMessage(@Body() body: InjectMessageDto) {
|
||||
try {
|
||||
return this.mesh.injectMessage(body?.text, body?.from);
|
||||
} catch (err) {
|
||||
throw new BadRequestException(
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Get('telemetry')
|
||||
@ApiOperation({
|
||||
summary: 'Telemetry history (last 100)',
|
||||
|
||||
@@ -561,6 +561,94 @@ describe('MeshService message history', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('MeshService injectMessage', () => {
|
||||
it('injects rx records that appear newest-first in messages()', () => {
|
||||
const svc = new MeshService();
|
||||
expect(svc.messages()).toEqual([]);
|
||||
|
||||
const r1 = svc.injectMessage('first');
|
||||
const r2 = svc.injectMessage('second', KAMIL_NUM);
|
||||
expect(r1.id).toBeGreaterThan(0); // random non-zero uint32, like tx ids
|
||||
expect(r2.id).toBeGreaterThan(0);
|
||||
|
||||
expect(svc.messages()).toEqual([
|
||||
{
|
||||
id: r2.id,
|
||||
dir: 'rx',
|
||||
from: KAMIL_NUM,
|
||||
text: 'second',
|
||||
channel: 0,
|
||||
at: expect.any(Number),
|
||||
},
|
||||
{
|
||||
id: r1.id,
|
||||
dir: 'rx',
|
||||
text: 'first',
|
||||
channel: 0,
|
||||
at: expect.any(Number),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('omits from when not provided and rejects empty text or invalid from', () => {
|
||||
const svc = new MeshService();
|
||||
const rec = svc.injectMessage('no sender');
|
||||
expect(svc.messages()[0]).toEqual({
|
||||
id: rec.id,
|
||||
dir: 'rx',
|
||||
text: 'no sender',
|
||||
channel: 0,
|
||||
at: expect.any(Number),
|
||||
});
|
||||
expect(svc.messages()[0]).not.toHaveProperty('from');
|
||||
|
||||
for (const bad of ['', undefined]) {
|
||||
expect(() => svc.injectMessage(bad as unknown as string)).toThrow(
|
||||
'text must be a non-empty string',
|
||||
);
|
||||
}
|
||||
for (const bad of [0, -1, 42.5, BROADCAST_ADDR + 1]) {
|
||||
expect(() => svc.injectMessage('x', bad)).toThrow(/invalid node number/);
|
||||
}
|
||||
expect(svc.messages()).toHaveLength(1); // rejected calls add nothing
|
||||
});
|
||||
|
||||
it('caps history at 100 records, dropping the oldest', () => {
|
||||
const svc = new MeshService();
|
||||
for (let i = 0; i < 105; i++) svc.injectMessage(`m-${i}`);
|
||||
expect(svc.messages()).toHaveLength(100);
|
||||
expect(svc.messages()[0]?.text).toBe('m-104'); // newest first
|
||||
expect(svc.messages()[99]?.text).toBe('m-5'); // m-0..m-4 dropped
|
||||
});
|
||||
|
||||
it('shares the history buffer with captured packets', 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,
|
||||
);
|
||||
|
||||
svc.injectMessage('injected');
|
||||
bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, 'real', 2));
|
||||
|
||||
await waitFor(() => svc.messages().length === 2);
|
||||
expect(svc.messages()[0]).toMatchObject({ dir: 'rx', text: 'real' }); // newest first
|
||||
expect(svc.messages()[1]).toMatchObject({
|
||||
dir: 'rx',
|
||||
text: 'injected',
|
||||
channel: 0,
|
||||
});
|
||||
} finally {
|
||||
svc.onModuleDestroy();
|
||||
bridge.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('MeshService telemetry', () => {
|
||||
it('captures device metrics and environment variants with from/kind/data', async () => {
|
||||
const bridge = await startBridge();
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
extractFrames,
|
||||
frame,
|
||||
loadHwModelNames,
|
||||
randomPacketId,
|
||||
} from './codec.js';
|
||||
|
||||
/** One entry in the message history: an inbound text (dir 'rx') or one of our sends (dir 'tx'). */
|
||||
@@ -303,6 +304,31 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
||||
return [...this.history].reverse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject a synthetic inbound (rx) record into the message history — as if received on the mesh.
|
||||
* NOT transmitted over RF: nothing is written to the bridge or air; the record exists so external
|
||||
* systems can push messages into the bot's inbox to trigger communication later. `from` is optional
|
||||
* (integer in 1..0xffffffff when provided) and omitted from the record when absent. The record rides
|
||||
* channel 0 like a primary-channel capture and appears in messages() newest first, subject to the
|
||||
* same HISTORY_LIMIT cap as real captures. Returns { id } with a random uint32 packet id (same
|
||||
* generation as tx sends). Throws on empty text or an invalid `from`.
|
||||
*/
|
||||
injectMessage(text: string, from?: number): { id: number } {
|
||||
if (typeof text !== 'string' || text.length === 0)
|
||||
throw new Error('text must be a non-empty string');
|
||||
if (from !== undefined) this.assertNodeNum(from);
|
||||
const rec: MessageRecord = {
|
||||
id: randomPacketId(),
|
||||
dir: 'rx',
|
||||
...(from !== undefined ? { from } : {}),
|
||||
text,
|
||||
channel: 0,
|
||||
at: Date.now(),
|
||||
};
|
||||
this.pushHistory(rec);
|
||||
return { id: rec.id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Telemetry records, newest first: TELEMETRY_APP packets from any node — device metrics
|
||||
* (battery/voltage/uptime/channel+air utilization) and environment sensor readings where nodes have them.
|
||||
|
||||
Reference in New Issue
Block a user