feat(api+broker+web): write-time mention fan-out via notification table
Phase 1 of v0.3.0 — replaces the regex-on-decoded-ciphertext scan in /v1/notifications and the dashboard MentionsSection with reads from a new mesh.notification table populated at write time. Schema 0025: mesh.notification (id, mesh_id, topic_id, message_id, recipient_member_id, sender_member_id, kind, created_at, read_at) with a unique (message_id, recipient) so a re-fanned message yields one row per recipient. Backfills existing v0.2.0 messages by regex-matching the (still-base64-plaintext) bodies — guarded with a base64 + length check so binary ciphertext doesn't crash the migration. Writers (POST /v1/messages + broker appendTopicMessage) now extract @-mentions from either an explicit `mentions: string[]` on the request OR a regex over the base64 plaintext (transitional fallback). Targets are intersected with the mesh roster + capped at 32 per message. Web chat panel sends the explicit array now so it keeps working after phase 2 lands. Readers switch to JOIN-on-notification: /v1/notifications — table-backed, supports ?unread=1 POST /v1/notifications/read — new, mark by ids or all-up-to MentionsSection (RSC) — same JOIN, returns readAt for each row GET /v1/notifications also gains a read_at field per row so a future bell UI can show unread vs read. Once per-topic encryption (phase 2) lands, the regex fallback becomes a no-op for v2 messages — clients MUST send `mentions`, which they already do. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -38,7 +38,9 @@ import {
|
||||
meshFileKey,
|
||||
meshContext,
|
||||
meshMember as memberTable,
|
||||
meshMember,
|
||||
meshMemory,
|
||||
meshNotification,
|
||||
meshState,
|
||||
meshService,
|
||||
meshSkill,
|
||||
@@ -694,6 +696,13 @@ export async function appendTopicMessage(args: {
|
||||
senderSessionPubkey?: string;
|
||||
nonce: string;
|
||||
ciphertext: string;
|
||||
/**
|
||||
* Optional client-extracted mention list (lowercased display names
|
||||
* without the leading @). Required once per-topic encryption lands —
|
||||
* the server can't read v0.3.0 ciphertext. Falls back to a regex on
|
||||
* the v0.2.0 base64 plaintext when omitted.
|
||||
*/
|
||||
mentions?: string[];
|
||||
}): Promise<string> {
|
||||
const [row] = await db
|
||||
.insert(meshTopicMessage)
|
||||
@@ -706,9 +715,94 @@ export async function appendTopicMessage(args: {
|
||||
})
|
||||
.returning({ id: meshTopicMessage.id });
|
||||
if (!row) throw new Error("failed to append topic message");
|
||||
|
||||
void fanOutMentions({
|
||||
messageId: row.id,
|
||||
topicId: args.topicId,
|
||||
senderMemberId: args.senderMemberId,
|
||||
ciphertext: args.ciphertext,
|
||||
explicit: args.mentions,
|
||||
}).catch(() => {
|
||||
// Notifications are advisory; don't fail the message write.
|
||||
});
|
||||
|
||||
return row.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract `@<displayName>` tokens from a base64-of-UTF8 plaintext body.
|
||||
* Capped at 16 tokens. Returns lowercased names without the @ prefix.
|
||||
*/
|
||||
function extractMentionTokens(b64: string): string[] {
|
||||
let text: string;
|
||||
try {
|
||||
text = Buffer.from(b64, "base64").toString("utf-8");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const found = new Set<string>();
|
||||
const re = /(^|[^A-Za-z0-9_-])@([A-Za-z0-9_-]{1,64})(?=$|[^A-Za-z0-9_-])/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
found.add(m[2]!.toLowerCase());
|
||||
if (found.size >= 16) break;
|
||||
}
|
||||
return [...found];
|
||||
}
|
||||
|
||||
async function fanOutMentions(args: {
|
||||
messageId: string;
|
||||
topicId: string;
|
||||
senderMemberId: string;
|
||||
ciphertext: string;
|
||||
explicit?: string[];
|
||||
}): Promise<void> {
|
||||
let tokens = args.explicit?.map((s) => s.toLowerCase().replace(/^@/, ""));
|
||||
if (!tokens || tokens.length === 0) {
|
||||
tokens = extractMentionTokens(args.ciphertext);
|
||||
}
|
||||
if (tokens.length === 0) return;
|
||||
|
||||
const [topic] = await db
|
||||
.select({ meshId: meshTopic.meshId })
|
||||
.from(meshTopic)
|
||||
.where(eq(meshTopic.id, args.topicId));
|
||||
if (!topic) return;
|
||||
|
||||
const recipients = await db
|
||||
.select({
|
||||
id: meshMember.id,
|
||||
displayName: meshMember.displayName,
|
||||
})
|
||||
.from(meshMember)
|
||||
.where(
|
||||
and(eq(meshMember.meshId, topic.meshId), isNull(meshMember.revokedAt)),
|
||||
);
|
||||
const tokenSet = new Set(tokens);
|
||||
const targets = recipients
|
||||
.filter(
|
||||
(r) =>
|
||||
tokenSet.has(r.displayName.toLowerCase()) &&
|
||||
r.id !== args.senderMemberId,
|
||||
)
|
||||
.slice(0, 32);
|
||||
if (targets.length === 0) return;
|
||||
|
||||
await db
|
||||
.insert(meshNotification)
|
||||
.values(
|
||||
targets.map((t) => ({
|
||||
meshId: topic.meshId,
|
||||
topicId: args.topicId,
|
||||
messageId: args.messageId,
|
||||
recipientMemberId: t.id,
|
||||
senderMemberId: args.senderMemberId,
|
||||
kind: "mention",
|
||||
})),
|
||||
)
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch topic history for a member. Pagination via `before` cursor (id of
|
||||
* an earlier message); pass null for the latest page.
|
||||
|
||||
Reference in New Issue
Block a user