rspace-online/modules/rchats/mod.ts

667 lines
23 KiB
TypeScript

/**
* 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<ChatsDirectoryDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<ChatsDirectoryDoc>(), '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<ChatChannelDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<ChatChannelDoc>(), '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<ChatsDirectoryDoc>(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<ChatsDirectoryDoc>(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<ChatsDirectoryDoc>(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<ChatChannelDoc>(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<ChatChannelDoc>(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<ChatChannelDoc>(docId, `edit message ${msgId}`, (d) => {
d.messages[msgId].content = content;
d.messages[msgId].editedAt = Date.now();
});
const updated = _syncServer!.getDoc<ChatChannelDoc>(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<ChatChannelDoc>(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<ChatChannelDoc>(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<ChatChannelDoc>(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<ChatChannelDoc>(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<ChatChannelDoc>(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<ChatChannelDoc>(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<ChatChannelDoc>(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<ChatsDirectoryDoc>(dirDocId)!;
if (!dir.channels[channelId]) {
_syncServer!.changeDoc<ChatsDirectoryDoc>(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<ChatChannelDoc>(dmDocId);
if (!doc) {
doc = Automerge.change(Automerge.init<ChatChannelDoc>(), '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<ChatChannelDoc>(dmDocId);
if (!doc) {
doc = Automerge.change(Automerge.init<ChatChannelDoc>(), '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<ChatChannelDoc>(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<ChatChannelDoc>(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<ChatChannelDoc>(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<ChatChannelDoc>(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<string, number> = {};
let total = 0;
for (const ch of Object.values(dir.channels || {})) {
if (ch.isDm) continue;
const doc = _syncServer!.getDoc<ChatChannelDoc>(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: `<folk-chat-app space="${space}"></folk-chat-app>`,
scripts: `<script type="module" src="/modules/rchats/folk-chat-app.js?v=1"></script>`,
}));
});
// ── 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<ChatChannelDoc>(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);
},
};