mesh: POST /api/respond + MCP respond tool — reply to a message by packet id
This commit is contained in:
+67
-15
@@ -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) };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user