/** * rChats module — encrypted community messaging with channels, threads, DMs. * * Local-first via Automerge CRDT. One doc per channel, directory doc per space. * DMs as private two-member channels with deterministic doc IDs. */ import { Hono } from "hono"; import * as Automerge from "@automerge/automerge"; import { renderShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule, SpaceLifecycleContext } from "../../shared/module"; import { verifyToken, extractToken } from "../../server/auth"; import { resolveCallerRole } from "../../server/spaces"; import type { SpaceRoleString } from "../../server/spaces"; import { filterByVisibility, filterArrayByVisibility } from "../../shared/membrane"; import { notify } from '../../server/notification-service'; import { renderLanding } from "./landing"; import type { SyncServer } from '../../server/local-first/sync-server'; import { chatsDirectorySchema, chatChannelSchema, chatsDirectoryDocId, chatChannelDocId, dmChannelDocId, } from './schemas'; import type { ChatsDirectoryDoc, ChatChannelDoc, ChannelInfo, ChatMessage, Transclusion, ThreadMeta, } from './schemas'; let _syncServer: SyncServer | null = null; const routes = new Hono(); // ── Local-first helpers ── function ensureDirectoryDoc(space: string): ChatsDirectoryDoc { const docId = chatsDirectoryDocId(space); let doc = _syncServer!.getDoc(docId); if (!doc) { doc = Automerge.change(Automerge.init(), 'init chats directory', (d) => { const init = chatsDirectorySchema.init(); Object.assign(d, init); d.meta.spaceSlug = space; }); _syncServer!.setDoc(docId, doc); } return doc; } function ensureChannelDoc(space: string, channelId: string, isDm = false): ChatChannelDoc { const docId = isDm ? channelId : chatChannelDocId(space, channelId); let doc = _syncServer!.getDoc(docId); if (!doc) { doc = Automerge.change(Automerge.init(), 'init channel', (d) => { const init = chatChannelSchema.init(); Object.assign(d, init); d.meta.spaceSlug = space; d.channelId = channelId; d.isDm = isDm; }); _syncServer!.setDoc(docId, doc); } return doc; } /** Seed "general" channel if none exist */ function seedGeneralChannel(space: string) { if (!_syncServer) return; const dir = ensureDirectoryDoc(space); if (Object.keys(dir.channels || {}).length > 0) return; const id = 'general'; const dirDocId = chatsDirectoryDocId(space); _syncServer.changeDoc(dirDocId, 'seed general channel', (d) => { d.channels[id] = { id, name: 'general', description: 'General discussion', isPrivate: false, isDm: false, createdBy: null, createdAt: Date.now(), updatedAt: Date.now(), }; }); ensureChannelDoc(space, id); } /** Extract @mentions from message content */ function extractMentions(content: string): string[] { const matches = content.match(/@([a-zA-Z0-9_.-]+)/g); return matches ? matches.map(m => m.slice(1)) : []; } // ── Auth helper ── async function requireAuth(c: any): Promise<{ claims: any; role: SpaceRoleString; space: string } | null> { const token = extractToken(c.req.raw.headers); if (!token) { c.json({ error: "Authentication required" }, 401); return null; } let claims; try { claims = await verifyToken(token); } catch { c.json({ error: "Invalid token" }, 401); return null; } if (!_syncServer) { c.json({ error: "Not initialized" }, 503); return null; } const space = c.req.param("space") || "demo"; let role: SpaceRoleString = 'viewer'; const resolved = await resolveCallerRole(space, claims); if (resolved) role = resolved.role; return { claims, role, space }; } // ── CRUD: Channels ── routes.get("/api/channels", async (c) => { if (!_syncServer) return c.json({ channels: [] }); const space = c.req.param("space") || "demo"; let callerRole: SpaceRoleString = 'viewer'; const token = extractToken(c.req.raw.headers); if (token) { try { const claims = await verifyToken(token); const resolved = await resolveCallerRole(space, claims); if (resolved) callerRole = resolved.role; } catch {} } const doc = ensureDirectoryDoc(space); const visibleChannels = filterByVisibility(doc.channels || {}, callerRole); // Filter out DM channels from the public list const publicChannels = Object.values(visibleChannels).filter(ch => !ch.isDm); return c.json({ channels: publicChannels }); }); routes.post("/api/channels", async (c) => { const auth = await requireAuth(c); if (!auth) return c.res; const { claims, space } = auth; const { name, description = "", isPrivate = false } = await c.req.json(); if (!name) return c.json({ error: "name required" }, 400); const id = crypto.randomUUID(); const docId = chatsDirectoryDocId(space); ensureDirectoryDoc(space); _syncServer!.changeDoc(docId, `create channel ${id}`, (d) => { d.channels[id] = { id, name, description, isPrivate, isDm: false, createdBy: claims.did || claims.sub || null, createdAt: Date.now(), updatedAt: Date.now(), }; }); ensureChannelDoc(space, id); const updated = _syncServer!.getDoc(docId)!; return c.json(updated.channels[id], 201); }); // ── CRUD: Messages ── routes.get("/api/channels/:channelId/messages", async (c) => { if (!_syncServer) return c.json({ messages: [] }); const space = c.req.param("space") || "demo"; const channelId = c.req.param("channelId"); let callerRole: SpaceRoleString = 'viewer'; const token = extractToken(c.req.raw.headers); if (token) { try { const claims = await verifyToken(token); const resolved = await resolveCallerRole(space, claims); if (resolved) callerRole = resolved.role; } catch {} } const doc = ensureChannelDoc(space, channelId); // Only return top-level messages (not thread replies) const messages = filterArrayByVisibility( Object.values(doc.messages || {}).filter(m => !m.threadId), callerRole, ).sort((a, b) => a.createdAt - b.createdAt); return c.json({ messages }); }); routes.post("/api/channels/:channelId/messages", async (c) => { const auth = await requireAuth(c); if (!auth) return c.res; const { claims, space } = auth; const channelId = c.req.param("channelId"); const { content, replyTo = null, transclusions = [] } = await c.req.json(); if (!content) return c.json({ error: "content required" }, 400); const id = crypto.randomUUID(); const docId = chatChannelDocId(space, channelId); ensureChannelDoc(space, channelId); const authorDid = claims.did || claims.sub || ''; const authorName = (claims.displayName as string) || claims.username || 'Anonymous'; _syncServer!.changeDoc(docId, `add message ${id}`, (d) => { d.messages[id] = { id, channelId, authorId: authorDid, authorName, content, replyTo, reactions: {}, transclusions: transclusions || [], editedAt: null, createdAt: Date.now(), }; }); // Notify channel members (skip sender) const updated = _syncServer!.getDoc(docId)!; const memberDids = Object.values(updated.members || {}) .map(m => m.userId).filter(did => did !== authorDid); for (const userDid of memberDids) { notify({ userDid, category: 'module', eventType: 'chat_message', title: `${authorName} in #${channelId}`, body: content.slice(0, 200), spaceSlug: space, moduleId: 'rchats', actionUrl: `/${space}/rchats?channel=${channelId}`, actorDid: authorDid, actorUsername: authorName, }).catch(() => {}); } // Notify @mentions const mentions = extractMentions(content); for (const mention of mentions) { // mentions are usernames — find DID from members const member = Object.values(updated.members || {}).find(m => m.displayName === mention); if (member && member.userId !== authorDid) { notify({ userDid: member.userId, category: 'module', eventType: 'chat_mention', title: `${authorName} mentioned you in #${channelId}`, body: content.slice(0, 200), spaceSlug: space, moduleId: 'rchats', actionUrl: `/${space}/rchats?channel=${channelId}`, actorDid: authorDid, actorUsername: authorName, }).catch(() => {}); } } return c.json(updated.messages[id], 201); }); // ── Edit message ── routes.put("/api/channels/:channelId/messages/:msgId", async (c) => { const auth = await requireAuth(c); if (!auth) return c.res; const { claims, space } = auth; const channelId = c.req.param("channelId"); const msgId = c.req.param("msgId"); const { content } = await c.req.json(); if (!content) return c.json({ error: "content required" }, 400); const docId = chatChannelDocId(space, channelId); const doc = ensureChannelDoc(space, channelId); if (!doc.messages[msgId]) return c.json({ error: "Not found" }, 404); const authorDid = claims.did || claims.sub || ''; if (doc.messages[msgId].authorId !== authorDid) { return c.json({ error: "Can only edit your own messages" }, 403); } _syncServer!.changeDoc(docId, `edit message ${msgId}`, (d) => { d.messages[msgId].content = content; d.messages[msgId].editedAt = Date.now(); }); const updated = _syncServer!.getDoc(docId)!; return c.json(updated.messages[msgId]); }); // ── Delete message ── routes.delete("/api/channels/:channelId/messages/:msgId", async (c) => { const auth = await requireAuth(c); if (!auth) return c.res; const { space } = auth; const channelId = c.req.param("channelId"); const msgId = c.req.param("msgId"); const docId = chatChannelDocId(space, channelId); const doc = ensureChannelDoc(space, channelId); if (!doc.messages[msgId]) return c.json({ error: "Not found" }, 404); _syncServer!.changeDoc(docId, `delete message ${msgId}`, (d) => { delete d.messages[msgId]; }); return c.json({ ok: true }); }); // ── Reactions ── routes.post("/api/channels/:channelId/messages/:msgId/react", async (c) => { const auth = await requireAuth(c); if (!auth) return c.res; const { claims, space } = auth; const channelId = c.req.param("channelId"); const msgId = c.req.param("msgId"); const { emoji } = await c.req.json(); if (!emoji) return c.json({ error: "emoji required" }, 400); const docId = chatChannelDocId(space, channelId); const doc = ensureChannelDoc(space, channelId); if (!doc.messages[msgId]) return c.json({ error: "Not found" }, 404); const userDid = claims.did || claims.sub || ''; _syncServer!.changeDoc(docId, `react ${emoji} on ${msgId}`, (d) => { const msg = d.messages[msgId]; if (!msg.reactions) msg.reactions = {} as any; if (!msg.reactions[emoji]) msg.reactions[emoji] = [] as any; const existing = msg.reactions[emoji] as unknown as string[]; const idx = existing.indexOf(userDid); if (idx >= 0) { existing.splice(idx, 1); if (existing.length === 0) delete msg.reactions[emoji]; } else { existing.push(userDid); } }); const updated = _syncServer!.getDoc(docId)!; return c.json({ reactions: updated.messages[msgId]?.reactions || {} }); }); // ── Threads ── routes.get("/api/channels/:channelId/threads/:threadId", async (c) => { if (!_syncServer) return c.json({ messages: [] }); const space = c.req.param("space") || "demo"; const channelId = c.req.param("channelId"); const threadId = c.req.param("threadId"); let callerRole: SpaceRoleString = 'viewer'; const token = extractToken(c.req.raw.headers); if (token) { try { const claims = await verifyToken(token); const resolved = await resolveCallerRole(space, claims); if (resolved) callerRole = resolved.role; } catch {} } const doc = ensureChannelDoc(space, channelId); // Return root message + all replies const rootMsg = doc.messages[threadId]; const replies = filterArrayByVisibility( Object.values(doc.messages || {}).filter(m => m.threadId === threadId), callerRole, ).sort((a, b) => a.createdAt - b.createdAt); const thread = doc.threads?.[threadId] || null; return c.json({ rootMessage: rootMsg || null, replies, thread }); }); routes.post("/api/channels/:channelId/messages/:msgId/thread", async (c) => { const auth = await requireAuth(c); if (!auth) return c.res; const { claims, space } = auth; const channelId = c.req.param("channelId"); const msgId = c.req.param("msgId"); const { content, transclusions = [] } = await c.req.json(); if (!content) return c.json({ error: "content required" }, 400); const docId = chatChannelDocId(space, channelId); const doc = ensureChannelDoc(space, channelId); if (!doc.messages[msgId]) return c.json({ error: "Root message not found" }, 404); const id = crypto.randomUUID(); const authorDid = claims.did || claims.sub || ''; const authorName = (claims.displayName as string) || claims.username || 'Anonymous'; _syncServer!.changeDoc(docId, `thread reply ${id}`, (d) => { d.messages[id] = { id, channelId, authorId: authorDid, authorName, content, replyTo: null, threadId: msgId, reactions: {}, transclusions: transclusions || [], editedAt: null, createdAt: Date.now(), }; // Update thread metadata if (!d.threads) d.threads = {} as any; if (!d.threads[msgId]) { d.threads[msgId] = { participantDids: [] as any, lastActivity: Date.now(), replyCount: 0, }; } const thread = d.threads[msgId]; thread.lastActivity = Date.now(); thread.replyCount = (thread.replyCount || 0) + 1; const participants = thread.participantDids as unknown as string[]; if (!participants.includes(authorDid)) { participants.push(authorDid); } }); // Notify thread participants (skip sender) const updated = _syncServer!.getDoc(docId)!; const threadMeta = updated.threads?.[msgId]; if (threadMeta) { const participantDids = (threadMeta.participantDids || []).filter((d: string) => d !== authorDid); // Also notify root message author const rootAuthor = updated.messages[msgId]?.authorId; if (rootAuthor && rootAuthor !== authorDid && !participantDids.includes(rootAuthor)) { participantDids.push(rootAuthor); } for (const userDid of participantDids) { notify({ userDid, category: 'module', eventType: 'chat_message', title: `${authorName} replied in thread`, body: content.slice(0, 200), spaceSlug: space, moduleId: 'rchats', actionUrl: `/${space}/rchats?channel=${channelId}&thread=${msgId}`, actorDid: authorDid, actorUsername: authorName, }).catch(() => {}); } } return c.json(updated.messages[id], 201); }); // ── Pins ── routes.post("/api/channels/:channelId/pins", async (c) => { const auth = await requireAuth(c); if (!auth) return c.res; const { space } = auth; const channelId = c.req.param("channelId"); const { messageId } = await c.req.json(); if (!messageId) return c.json({ error: "messageId required" }, 400); const docId = chatChannelDocId(space, channelId); const doc = ensureChannelDoc(space, channelId); if (!doc.messages[messageId]) return c.json({ error: "Message not found" }, 404); _syncServer!.changeDoc(docId, `toggle pin ${messageId}`, (d) => { if (!d.pins) d.pins = [] as any; const pins = d.pins as unknown as string[]; const idx = pins.indexOf(messageId); if (idx >= 0) { pins.splice(idx, 1); } else { pins.push(messageId); } }); const updated = _syncServer!.getDoc(docId)!; return c.json({ pins: updated.pins || [] }); }); // ── DMs ── routes.get("/api/dm", async (c) => { const auth = await requireAuth(c); if (!auth) return c.res; const { claims, space } = auth; const myDid = claims.did || claims.sub || ''; // Scan directory for DM channels involving this user const dir = ensureDirectoryDoc(space); const dmChannels = Object.values(dir.channels || {}) .filter(ch => ch.isDm && ch.id.includes(myDid)); return c.json({ channels: dmChannels }); }); routes.get("/api/dm/:targetDid", async (c) => { const auth = await requireAuth(c); if (!auth) return c.res; const { claims, space } = auth; const myDid = claims.did || claims.sub || ''; const targetDid = c.req.param("targetDid"); if (myDid === targetDid) return c.json({ error: "Cannot DM yourself" }, 400); const dmDocId = dmChannelDocId(space, myDid, targetDid); const channelId = `dm:${[myDid, targetDid].sort().join('+')}`; // Ensure DM channel exists in directory const dirDocId = chatsDirectoryDocId(space); ensureDirectoryDoc(space); const dir = _syncServer!.getDoc(dirDocId)!; if (!dir.channels[channelId]) { _syncServer!.changeDoc(dirDocId, `create DM ${channelId}`, (d) => { d.channels[channelId] = { id: channelId, name: `DM`, description: '', isPrivate: true, isDm: true, createdBy: myDid, createdAt: Date.now(), updatedAt: Date.now(), }; }); } // Ensure channel doc exists let doc = _syncServer!.getDoc(dmDocId); if (!doc) { doc = Automerge.change(Automerge.init(), 'init DM channel', (d) => { const init = chatChannelSchema.init(); Object.assign(d, init); d.meta.spaceSlug = space; d.channelId = channelId; d.isDm = true; d.members[myDid] = { userId: myDid, displayName: claims.displayName || claims.username || 'User', joinedAt: Date.now() }; d.members[targetDid] = { userId: targetDid, displayName: targetDid, joinedAt: Date.now() }; }); _syncServer!.setDoc(dmDocId, doc); } return c.json({ channelId, docId: dmDocId, messages: Object.values(doc.messages || {}).sort((a, b) => a.createdAt - b.createdAt), members: Object.values(doc.members || {}), }); }); routes.post("/api/dm/:targetDid/messages", async (c) => { const auth = await requireAuth(c); if (!auth) return c.res; const { claims, space } = auth; const myDid = claims.did || claims.sub || ''; const targetDid = c.req.param("targetDid"); const { content, transclusions = [] } = await c.req.json(); if (!content) return c.json({ error: "content required" }, 400); const dmDocId = dmChannelDocId(space, myDid, targetDid); const channelId = `dm:${[myDid, targetDid].sort().join('+')}`; // Ensure DM exists first (reuse GET logic) let doc = _syncServer!.getDoc(dmDocId); if (!doc) { doc = Automerge.change(Automerge.init(), 'init DM channel', (d) => { const init = chatChannelSchema.init(); Object.assign(d, init); d.meta.spaceSlug = space; d.channelId = channelId; d.isDm = true; }); _syncServer!.setDoc(dmDocId, doc); } const id = crypto.randomUUID(); const authorName = (claims.displayName as string) || claims.username || 'Anonymous'; _syncServer!.changeDoc(dmDocId, `DM message ${id}`, (d) => { d.messages[id] = { id, channelId, authorId: myDid, authorName, content, replyTo: null, reactions: {}, transclusions: transclusions || [], editedAt: null, createdAt: Date.now(), }; }); // Notify recipient notify({ userDid: targetDid, category: 'module', eventType: 'chat_dm', title: `DM from ${authorName}`, body: content.slice(0, 200), spaceSlug: space, moduleId: 'rchats', actionUrl: `/${space}/rchats?dm=${myDid}`, actorDid: myDid, actorUsername: authorName, }).catch(() => {}); const updated = _syncServer!.getDoc(dmDocId)!; return c.json(updated.messages[id], 201); }); // ── Members (join/leave) ── routes.post("/api/channels/:channelId/join", async (c) => { const auth = await requireAuth(c); if (!auth) return c.res; const { claims, space } = auth; const channelId = c.req.param("channelId"); const docId = chatChannelDocId(space, channelId); ensureChannelDoc(space, channelId); const userDid = claims.did || claims.sub || ''; _syncServer!.changeDoc(docId, `join ${userDid}`, (d) => { d.members[userDid] = { userId: userDid, displayName: (claims.displayName as string) || claims.username || 'Anonymous', joinedAt: Date.now(), }; }); return c.json({ ok: true }); }); routes.post("/api/channels/:channelId/leave", async (c) => { const auth = await requireAuth(c); if (!auth) return c.res; const { claims, space } = auth; const channelId = c.req.param("channelId"); const docId = chatChannelDocId(space, channelId); const userDid = claims.did || claims.sub || ''; _syncServer!.changeDoc(docId, `leave ${userDid}`, (d) => { delete d.members[userDid]; }); return c.json({ ok: true }); }); // ── Unread count ── routes.get("/api/unread-count", async (c) => { if (!_syncServer) return c.json({ count: 0, channels: {} }); const space = c.req.param("space") || "demo"; const sinceParam = c.req.query("since"); const since = sinceParam ? parseInt(sinceParam, 10) : 0; const dir = ensureDirectoryDoc(space); const result: Record = {}; let total = 0; for (const ch of Object.values(dir.channels || {})) { if (ch.isDm) continue; const doc = _syncServer!.getDoc(chatChannelDocId(space, ch.id)); if (!doc?.messages) continue; const count = Object.values(doc.messages).filter(m => !m.threadId && m.createdAt > since).length; if (count > 0) { result[ch.id] = count; total += count; } } return c.json({ count: total, channels: result }); }); // ── Hub page (active chat UI) ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; return c.html(renderShell({ title: `rChats — ${space} | rSpace`, moduleId: "rchats", spaceSlug: space, modules: getModuleInfoList(), body: ``, scripts: ``, })); }); // ── MI Integration ── export function getRecentMessagesForMI(space: string, limit = 5): { id: string; channel: string; author: string; content: string; createdAt: number }[] { if (!_syncServer) return []; const all: { id: string; channel: string; author: string; content: string; createdAt: number }[] = []; for (const docId of _syncServer.listDocs()) { if (!docId.startsWith(`${space}:chats:channel:`)) continue; const doc = _syncServer.getDoc(docId); if (!doc?.messages) continue; for (const msg of Object.values(doc.messages)) { all.push({ id: msg.id, channel: msg.channelId, author: msg.authorName, content: msg.content.slice(0, 200), createdAt: msg.createdAt }); } } return all.sort((a, b) => b.createdAt - a.createdAt).slice(0, limit); } // ── Module export ── export const chatsModule: RSpaceModule = { id: "rchats", name: "rChats", icon: "\u{1F4AC}", description: "Encrypted community messaging", scoping: { defaultScope: "space", userConfigurable: false }, docSchemas: [ { pattern: '{space}:chats:channels', description: 'Channel directory per space', init: chatsDirectorySchema.init }, { pattern: '{space}:chats:channel:{channelId}', description: 'Messages per channel', init: chatChannelSchema.init }, { pattern: '{space}:chats:dm:{did1}+{did2}', description: 'DM channel', init: chatChannelSchema.init }, ], routes, landingPage: renderLanding, async onInit(ctx) { _syncServer = ctx.syncServer; }, async onSpaceCreate(ctx: SpaceLifecycleContext) { if (!_syncServer) return; seedGeneralChannel(ctx.spaceSlug); }, };