feat(broker+api+cli): topic message reply-to threading (v0.3.1)
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled

Adds a reply_to_id column (self-FK on topic_message) plus end-to-end
plumbing so a message can mark itself as a reply to a previous one in
the same topic.

- Schema: 0027_topic_message_reply_to.sql adds reply_to_id with
  ON DELETE SET NULL + index for backlink lookup.
- Broker: appendTopicMessage validates parent shares the topic, writes
  reply_to_id; topicHistory + topic_history_response surface it; WS
  push envelope now carries senderMemberId, senderName, topic name,
  reply_to_id, and message_id so recipients have everything they need
  to reply without a follow-up query.
- REST: POST /v1/messages accepts replyToId (validated server-side);
  GET /messages and SSE /stream emit it per row.
- CLI: \`topic post --reply-to <id|prefix>\` resolves prefixes against
  recent history; \`topic tail\` renders an "↳ in reply to <name>:
  <snippet>" line above replies and shows a copyable #shortid tag on
  every row.
- MCP push pipe: channel attributes now include from_pubkey,
  from_member_id, message_id, topic, reply_to_id — the recipient can
  thread a reply directly from the inbound notification.
- Skill + identity prompt updated to teach Claude how to use the new
  attributes for replies.

Bumped CLI to 1.9.0.
This commit is contained in:
Alejandro Gutiérrez
2026-05-02 21:58:21 +01:00
parent d871988084
commit 038a5b5bf7
13 changed files with 273 additions and 19 deletions

View File

@@ -838,6 +838,13 @@ export async function appendTopicMessage(args: {
senderSessionPubkey?: string;
nonce: string;
ciphertext: string;
bodyVersion?: number;
/**
* Optional id of the parent topic message this one replies to. Server
* verifies the parent exists and lives in the same topic; otherwise
* silently drops the reference (treated as a top-level post).
*/
replyToId?: string;
/**
* Optional client-extracted mention list (lowercased display names
* without the leading @). Required once per-topic encryption lands —
@@ -846,6 +853,17 @@ export async function appendTopicMessage(args: {
*/
mentions?: string[];
}): Promise<string> {
let validatedReplyTo: string | null = null;
if (args.replyToId) {
const [parent] = await db
.select({ id: meshTopicMessage.id, topicId: meshTopicMessage.topicId })
.from(meshTopicMessage)
.where(eq(meshTopicMessage.id, args.replyToId));
if (parent && parent.topicId === args.topicId) {
validatedReplyTo = parent.id;
}
}
const [row] = await db
.insert(meshTopicMessage)
.values({
@@ -854,6 +872,8 @@ export async function appendTopicMessage(args: {
senderSessionPubkey: args.senderSessionPubkey ?? null,
nonce: args.nonce,
ciphertext: args.ciphertext,
bodyVersion: args.bodyVersion ?? 1,
replyToId: validatedReplyTo,
})
.returning({ id: meshTopicMessage.id });
if (!row) throw new Error("failed to append topic message");
@@ -958,8 +978,11 @@ export async function topicHistory(args: {
id: string;
senderMemberId: string;
senderPubkey: string;
senderName: string;
nonce: string;
ciphertext: string;
bodyVersion: number;
replyToId: string | null;
createdAt: Date;
}>
> {
@@ -971,13 +994,18 @@ export async function topicHistory(args: {
id: string;
sender_member_id: string;
sender_pubkey: string;
sender_name: string;
nonce: string;
ciphertext: string;
body_version: number;
reply_to_id: string | null;
created_at: Date;
}>(sql`
SELECT tm.id, tm.sender_member_id,
COALESCE(tm.sender_session_pubkey, m.peer_pubkey) AS sender_pubkey,
tm.nonce, tm.ciphertext, tm.created_at
m.display_name AS sender_name,
tm.nonce, tm.ciphertext, tm.body_version, tm.reply_to_id,
tm.created_at
FROM mesh.topic_message tm
JOIN mesh.member m ON m.id = tm.sender_member_id
WHERE tm.topic_id = ${args.topicId}
@@ -989,16 +1017,22 @@ export async function topicHistory(args: {
id: string;
sender_member_id: string;
sender_pubkey: string;
sender_name: string;
nonce: string;
ciphertext: string;
body_version: number;
reply_to_id: string | null;
created_at: Date;
}>;
return rows.map((r) => ({
id: r.id,
senderMemberId: r.sender_member_id,
senderPubkey: r.sender_pubkey,
senderName: r.sender_name,
nonce: r.nonce,
ciphertext: r.ciphertext,
bodyVersion: r.body_version ?? 1,
replyToId: r.reply_to_id,
createdAt: r.created_at instanceof Date ? r.created_at : new Date(r.created_at),
}));
}