mesh: POST /api/respond + MCP respond tool — reply to a message by packet id

This commit is contained in:
2026-09-25 15:32:34 +00:00
parent 88b617f8b7
commit c33f4613b5
6 changed files with 422 additions and 44 deletions
+67 -15
View File
@@ -1,6 +1,8 @@
import {
BadRequestException,
Injectable,
Logger,
NotFoundException,
OnModuleDestroy,
OnModuleInit,
ServiceUnavailableException,
@@ -84,6 +86,22 @@ export function initMessagesTable(db: DatabaseSync): void {
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. */
type NodeEntry = Pick<Proto.Mesh.NodeInfo, 'num'> &
Partial<Omit<Proto.Mesh.NodeInfo, 'num'>>;
@@ -393,21 +411,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 ?',
).all(HISTORY_LIMIT) as Array<Record<string, unknown>>;
// rows come back newest first — the buffer is oldest-first
for (const row of [...rows].reverse()) {
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);
this.history.push(rec);
}
for (const row of [...rows].reverse())
this.history.push(recordFromRow(row));
}
/** Look up a history record by packet id: in-memory buffer first, then SQLite (newest 1000). */
private findRecord(id: number): MessageRecord | undefined {
const hit = this.history.find((r) => r.id === id);
if (hit) return hit;
const db = this.db;
if (!db) return undefined;
const row = db
.prepare(
'SELECT id, dir, to_node, from_node, text, channel, at, status, ack_error FROM messages WHERE id = ?',
)
.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) };
}
/**