Queue read_state_update events when users ack DM messages on channels with a federatedId. Translates local message IDs to federation coordinates using sourceInstance/sourceMessageId.
2168 lines
72 KiB
TypeScript
2168 lines
72 KiB
TypeScript
import type { WebSocket } from 'ws';
|
|
import { eq, inArray, and } from 'drizzle-orm';
|
|
import { getDb, schema } from '../db/index.js';
|
|
import { generateSnowflake } from '../utils/snowflake.js';
|
|
import { connectionManager } from './handler.js';
|
|
import type { VoiceRoom, DmRoomMeta, SpaceRoomMeta } from './handler.js';
|
|
import { isMember, getChannelSpaceId, isDmMember, hasPermission, computePermissions, PermissionBits } from '../utils/permissions.js';
|
|
import { broadcastDmMessage, getDmMessageWithUser } from '../routes/dm.js';
|
|
import { MAX_MESSAGE_LENGTH, type MessageWithUser, type Attachment, type DmMessageWithUser, type Embed, type Activity, type ActivityType, type ActivityTimestamps, type ActivityAssets } from '@backspace/shared';
|
|
import { ACTIVITY_LIMITS } from '@backspace/shared/src/activities.js';
|
|
import { sanitizeUser } from '../utils/sanitize.js';
|
|
import { deleteAttachmentFiles } from '../utils/fileCleanup.js';
|
|
import { resolveEmbeds, reResolveEmbeds, embedRowToEmbed } from '../utils/embedResolver.js';
|
|
import { appendMutationLog, queueOutboxEvent, queueDmRelay, getGroupDmTargetOrigins, sendCallRelay, computeFederatedId, sendTypingRelay, queueReadStateRelay } from '../utils/federationOutbox.js';
|
|
import { getOurOrigin } from '../utils/federationAuth.js';
|
|
import { generateFederatedCallToken } from '../routes/livekit.js';
|
|
import { config } from '../config.js';
|
|
import crypto from 'node:crypto';
|
|
|
|
/**
|
|
* Re-evaluate SPEAK permission for all participants in voice channels
|
|
* belonging to the given space. On transition, updates the in-memory
|
|
* permissionMutedUsers Set and broadcasts voice_permission_muted events.
|
|
*/
|
|
export function checkVoicePermissions(spaceId: string): void {
|
|
for (const [roomId, room] of connectionManager.getAllRooms()) {
|
|
if (room.roomType !== 'space') continue;
|
|
const meta = room.metadata as SpaceRoomMeta;
|
|
if (meta.spaceId !== spaceId) continue;
|
|
|
|
for (const userId of room.participants) {
|
|
const perms = computePermissions(userId, spaceId, roomId);
|
|
const canSpeak = (perms & PermissionBits.SPEAK) !== 0n || (perms & PermissionBits.ADMINISTRATOR) !== 0n;
|
|
const wasMuted = connectionManager.isPermissionMuted(spaceId, userId);
|
|
const shouldMute = !canSpeak;
|
|
|
|
if (shouldMute !== wasMuted) {
|
|
connectionManager.setPermissionMuted(spaceId, userId, shouldMute);
|
|
connectionManager.sendToSpace(spaceId, {
|
|
type: 'voice_permission_muted',
|
|
userId,
|
|
spaceId,
|
|
muted: shouldMute,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function getMessageWithUser(messageId: string): MessageWithUser | null {
|
|
const db = getDb();
|
|
const message = db.select().from(schema.messages).where(eq(schema.messages.id, messageId)).get();
|
|
if (!message) return null;
|
|
|
|
const user = db.select().from(schema.users).where(eq(schema.users.id, message.userId)).get();
|
|
if (!user) return null;
|
|
|
|
const attachmentRows = db.select()
|
|
.from(schema.attachments)
|
|
.where(eq(schema.attachments.messageId, messageId))
|
|
.all();
|
|
|
|
const attachments: Attachment[] = attachmentRows.map(a => ({
|
|
id: a.id,
|
|
messageId: a.messageId ?? messageId,
|
|
filename: a.filename,
|
|
originalName: a.originalName,
|
|
mimetype: a.mimetype,
|
|
size: a.size,
|
|
thumbnailFilename: a.thumbnailFilename ?? null,
|
|
width: a.width ?? null,
|
|
height: a.height ?? null,
|
|
duration: a.duration ?? null,
|
|
createdAt: a.createdAt,
|
|
}));
|
|
|
|
const reactionRows = db.select()
|
|
.from(schema.reactions)
|
|
.where(eq(schema.reactions.messageId, messageId))
|
|
.all();
|
|
|
|
const reactions = reactionRows.map(r => ({
|
|
id: r.id,
|
|
messageId: r.messageId,
|
|
userId: r.userId,
|
|
emoji: r.emoji,
|
|
createdAt: r.createdAt,
|
|
}));
|
|
|
|
const embedRows = db.select()
|
|
.from(schema.embeds)
|
|
.where(eq(schema.embeds.messageId, messageId))
|
|
.all();
|
|
|
|
let replyTo: MessageWithUser | null = null;
|
|
if (message.replyToId) {
|
|
// Simple fetch for replyTo (one level deep to avoid recursion loops)
|
|
const replyMsg = db.select().from(schema.messages).where(eq(schema.messages.id, message.replyToId)).get();
|
|
if (replyMsg) {
|
|
const replyUser = db.select().from(schema.users).where(eq(schema.users.id, replyMsg.userId)).get();
|
|
if (replyUser) {
|
|
replyTo = {
|
|
id: replyMsg.id,
|
|
channelId: replyMsg.channelId,
|
|
userId: replyMsg.userId,
|
|
replyToId: replyMsg.replyToId,
|
|
content: replyMsg.content,
|
|
editedAt: replyMsg.editedAt,
|
|
createdAt: replyMsg.createdAt,
|
|
user: sanitizeUser(replyUser),
|
|
attachments: [], // Don't fetch attachments for replies to save bandwidth
|
|
embeds: [],
|
|
reactions: [], // Don't fetch reactions for replies
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
id: message.id,
|
|
channelId: message.channelId,
|
|
userId: message.userId,
|
|
replyToId: message.replyToId,
|
|
content: message.content,
|
|
editedAt: message.editedAt,
|
|
createdAt: message.createdAt,
|
|
user: sanitizeUser(user),
|
|
attachments,
|
|
embeds: embedRows.map(e => embedRowToEmbed(e)),
|
|
reactions,
|
|
replyTo,
|
|
};
|
|
}
|
|
|
|
// Typing timeout tracking (capped to prevent unbounded growth)
|
|
const MAX_TYPING_ENTRIES = 10_000;
|
|
const typingTimeouts: Map<string, NodeJS.Timeout> = new Map();
|
|
|
|
export function handleClientEvent(
|
|
event: Record<string, unknown>,
|
|
userId: string,
|
|
username: string,
|
|
ws: WebSocket,
|
|
isFederated: boolean,
|
|
): void {
|
|
const type = event.type as string;
|
|
|
|
// Federation gating: DM calls remain blocked for federated users (separate scope)
|
|
const DM_CALL_EVENTS = ['dm_call_start', 'dm_call_accept', 'dm_call_reject', 'dm_call_end'];
|
|
if (isFederated && DM_CALL_EVENTS.includes(type)) {
|
|
connectionManager.sendToUser(userId, {
|
|
type: 'error',
|
|
message: 'Federated users cannot use DM calls on remote instances',
|
|
});
|
|
return;
|
|
}
|
|
|
|
switch (type) {
|
|
case 'message_create':
|
|
handleMessageCreate(event, userId);
|
|
break;
|
|
case 'message_edit':
|
|
handleMessageEdit(event, userId);
|
|
break;
|
|
case 'message_delete':
|
|
handleMessageDelete(event, userId);
|
|
break;
|
|
case 'typing_start':
|
|
handleTypingStart(event, userId, username);
|
|
break;
|
|
case 'presence_update':
|
|
handlePresenceUpdate(event, userId);
|
|
break;
|
|
case 'voice_join':
|
|
handleVoiceJoin(event, userId, ws);
|
|
break;
|
|
case 'voice_leave':
|
|
handleVoiceLeave(userId);
|
|
break;
|
|
case 'dm_message_create':
|
|
handleDmMessageCreate(event, userId);
|
|
break;
|
|
case 'dm_typing_start':
|
|
handleDmTypingStart(event, userId, username);
|
|
break;
|
|
case 'dm_message_edit':
|
|
handleDmMessageEdit(event, userId);
|
|
break;
|
|
case 'dm_message_delete':
|
|
handleDmMessageDelete(event, userId);
|
|
break;
|
|
case 'reaction_add':
|
|
handleReactionAdd(event, userId, isFederated);
|
|
break;
|
|
case 'reaction_remove':
|
|
handleReactionRemove(event, userId, isFederated);
|
|
break;
|
|
case 'channel_ack':
|
|
handleChannelAck(event, userId, isFederated);
|
|
break;
|
|
case 'mark_unread':
|
|
handleMarkUnread(event, userId, isFederated);
|
|
break;
|
|
case 'dm_call_start':
|
|
handleDmCallStart(event, userId, username, ws);
|
|
break;
|
|
case 'dm_call_accept':
|
|
handleDmCallAccept(event, userId, ws);
|
|
break;
|
|
case 'dm_call_reject':
|
|
handleDmCallReject(event, userId);
|
|
break;
|
|
case 'dm_call_end':
|
|
handleDmCallEnd(event, userId);
|
|
break;
|
|
case 'voice_status':
|
|
handleVoiceStatus(event, userId);
|
|
break;
|
|
case 'voice_space_mute':
|
|
handleVoiceSpaceMute(event, userId);
|
|
break;
|
|
case 'voice_space_deafen':
|
|
handleVoiceSpaceDeafen(event, userId);
|
|
break;
|
|
case 'voice_move':
|
|
handleVoiceMove(event, userId);
|
|
break;
|
|
case 'voice_disconnect':
|
|
handleVoiceDisconnect(event, userId);
|
|
break;
|
|
case 'activity_update':
|
|
handleActivityUpdate(event, userId);
|
|
break;
|
|
default:
|
|
connectionManager.sendToUser(userId, {
|
|
type: 'error',
|
|
message: `Unknown event type: ${type}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
function handleMessageCreate(event: Record<string, unknown>, userId: string): void {
|
|
const channelId = event.channelId as string;
|
|
const content = event.content as string;
|
|
const replyToId = event.replyToId as string | undefined;
|
|
|
|
if (!channelId || typeof channelId !== 'string') {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: 'channelId is required' });
|
|
return;
|
|
}
|
|
|
|
if (!content || typeof content !== 'string' || content.trim().length === 0) {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: 'content is required' });
|
|
return;
|
|
}
|
|
|
|
if (content.length > MAX_MESSAGE_LENGTH) {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: `Message content must be ${MAX_MESSAGE_LENGTH} characters or less` });
|
|
return;
|
|
}
|
|
|
|
const spaceId = getChannelSpaceId(channelId);
|
|
if (!spaceId) {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: 'Channel not found' });
|
|
return;
|
|
}
|
|
|
|
if (!hasPermission(userId, spaceId, PermissionBits.SEND_MESSAGES, channelId)) {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: 'Missing SEND_MESSAGES permission' });
|
|
return;
|
|
}
|
|
|
|
const db = getDb();
|
|
const messageId = generateSnowflake();
|
|
const now = Date.now();
|
|
|
|
db.insert(schema.messages).values({
|
|
id: messageId,
|
|
channelId,
|
|
userId,
|
|
replyToId: replyToId || null,
|
|
content: content.trim(),
|
|
createdAt: now,
|
|
}).run();
|
|
|
|
const messageWithUser = getMessageWithUser(messageId);
|
|
if (messageWithUser) {
|
|
// Broadcast to members with VIEW_CHANNEL on this channel
|
|
connectionManager.sendToChannel(spaceId, channelId, {
|
|
type: 'message_created',
|
|
message: messageWithUser,
|
|
});
|
|
|
|
// Resolve embeds asynchronously
|
|
setImmediate(() => {
|
|
resolveEmbeds(messageId, content.trim(), channelId, false, spaceId).catch(() => {});
|
|
});
|
|
}
|
|
}
|
|
|
|
function handleMessageEdit(event: Record<string, unknown>, userId: string): void {
|
|
const messageId = event.messageId as string;
|
|
const content = event.content as string;
|
|
|
|
if (!messageId || typeof messageId !== 'string') {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: 'messageId is required' });
|
|
return;
|
|
}
|
|
|
|
if (!content || typeof content !== 'string' || content.trim().length === 0) {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: 'content is required' });
|
|
return;
|
|
}
|
|
|
|
if (content.length > MAX_MESSAGE_LENGTH) {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: `Message content must be ${MAX_MESSAGE_LENGTH} characters or less` });
|
|
return;
|
|
}
|
|
|
|
const db = getDb();
|
|
const message = db.select().from(schema.messages).where(eq(schema.messages.id, messageId)).get();
|
|
if (!message) {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: 'Message not found' });
|
|
return;
|
|
}
|
|
|
|
if (message.userId !== userId) {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: 'You can only edit your own messages' });
|
|
return;
|
|
}
|
|
|
|
const now = Date.now();
|
|
db.update(schema.messages)
|
|
.set({ content: content.trim(), editedAt: now })
|
|
.where(eq(schema.messages.id, messageId))
|
|
.run();
|
|
|
|
const spaceId = getChannelSpaceId(message.channelId);
|
|
if (!spaceId) return;
|
|
|
|
// Delete old embeds synchronously so the broadcast reflects the edit
|
|
db.delete(schema.embeds).where(eq(schema.embeds.messageId, messageId)).run();
|
|
|
|
const updatedMessage = getMessageWithUser(messageId);
|
|
if (updatedMessage) {
|
|
connectionManager.sendToChannel(spaceId, message.channelId, {
|
|
type: 'message_updated',
|
|
message: updatedMessage,
|
|
});
|
|
|
|
// Resolve new embeds asynchronously (old ones already deleted above)
|
|
setImmediate(() => {
|
|
resolveEmbeds(messageId, content.trim(), message.channelId, false, spaceId).catch(() => {});
|
|
});
|
|
}
|
|
}
|
|
|
|
function handleMessageDelete(event: Record<string, unknown>, userId: string): void {
|
|
const messageId = event.messageId as string;
|
|
|
|
if (!messageId || typeof messageId !== 'string') {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: 'messageId is required' });
|
|
return;
|
|
}
|
|
|
|
const db = getDb();
|
|
const message = db.select().from(schema.messages).where(eq(schema.messages.id, messageId)).get();
|
|
if (!message) {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: 'Message not found' });
|
|
return;
|
|
}
|
|
|
|
const spaceId = getChannelSpaceId(message.channelId);
|
|
if (!spaceId) return;
|
|
|
|
// Allow author or MANAGE_MESSAGES permission holder to delete
|
|
const isAuthor = message.userId === userId;
|
|
const canManageMessages = hasPermission(userId, spaceId, PermissionBits.MANAGE_MESSAGES, message.channelId);
|
|
|
|
if (!isAuthor && !canManageMessages) {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: 'You cannot delete this message' });
|
|
return;
|
|
}
|
|
|
|
// Collect attachment filenames before deletion
|
|
const attachmentRows = db.select({ filename: schema.attachments.filename })
|
|
.from(schema.attachments).where(eq(schema.attachments.messageId, messageId)).all();
|
|
|
|
// Delete attachments + message atomically, file cleanup outside transaction
|
|
db.transaction((tx) => {
|
|
tx.delete(schema.attachments).where(eq(schema.attachments.messageId, messageId)).run();
|
|
tx.delete(schema.messages).where(eq(schema.messages.id, messageId)).run();
|
|
});
|
|
|
|
// Clean up files from disk (outside transaction — file I/O)
|
|
deleteAttachmentFiles(attachmentRows);
|
|
|
|
connectionManager.sendToChannel(spaceId, message.channelId, {
|
|
type: 'message_deleted',
|
|
messageId,
|
|
channelId: message.channelId,
|
|
});
|
|
}
|
|
|
|
function handleTypingStart(event: Record<string, unknown>, userId: string, username: string): void {
|
|
const channelId = event.channelId as string;
|
|
|
|
if (!channelId || typeof channelId !== 'string') return;
|
|
|
|
const spaceId = getChannelSpaceId(channelId);
|
|
if (!spaceId) return;
|
|
|
|
if (!hasPermission(userId, spaceId, PermissionBits.SEND_MESSAGES, channelId)) return;
|
|
|
|
// Clear previous typing timeout for this user+channel
|
|
const key = `${userId}:${channelId}`;
|
|
const existing = typingTimeouts.get(key);
|
|
if (existing) {
|
|
clearTimeout(existing);
|
|
}
|
|
|
|
// Broadcast typing event to channel viewers (exclude sender)
|
|
connectionManager.sendToChannel(spaceId, channelId, {
|
|
type: 'typing',
|
|
channelId,
|
|
userId,
|
|
username,
|
|
}, userId);
|
|
|
|
// Safety cap: skip if Map is at max capacity (auto-expiry handles normal cleanup)
|
|
if (!existing && typingTimeouts.size >= MAX_TYPING_ENTRIES) return;
|
|
|
|
// Auto-expire typing after 5 seconds
|
|
const timeout = setTimeout(() => {
|
|
typingTimeouts.delete(key);
|
|
}, 5000);
|
|
typingTimeouts.set(key, timeout);
|
|
}
|
|
|
|
// ─── Activity Validation ──────────────────────────────────────────────────
|
|
|
|
const VALID_ACTIVITY_TYPES = new Set<string>(['custom', 'playing', 'listening', 'watching', 'streaming']);
|
|
const MAX_TIMESTAMP = 4102444800000;
|
|
|
|
function validateActivities(raw: unknown): Activity[] | null {
|
|
if (!Array.isArray(raw)) return null;
|
|
if (raw.length > ACTIVITY_LIMITS.MAX_ACTIVITIES_PER_USER) return null;
|
|
|
|
const validated: Activity[] = [];
|
|
for (const item of raw) {
|
|
if (!item || typeof item !== 'object') return null;
|
|
const obj = item as Record<string, unknown>;
|
|
if (!VALID_ACTIVITY_TYPES.has(obj.type as string)) return null;
|
|
if (typeof obj.name !== 'string') return null;
|
|
if (obj.name.length === 0 || obj.name.length > ACTIVITY_LIMITS.MAX_NAME_LENGTH) return null;
|
|
|
|
const activity: Activity = { type: obj.type as ActivityType, name: (obj.name as string).trim() };
|
|
|
|
if (typeof obj.details === 'string' && obj.details.length <= ACTIVITY_LIMITS.MAX_DETAILS_LENGTH) activity.details = obj.details.trim();
|
|
if (typeof obj.state === 'string' && obj.state.length <= ACTIVITY_LIMITS.MAX_STATE_LENGTH) activity.state = obj.state.trim();
|
|
if (typeof obj.url === 'string' && obj.url.length <= ACTIVITY_LIMITS.MAX_URL_LENGTH) {
|
|
if (obj.url.startsWith('https://') || obj.url.startsWith('http://')) activity.url = obj.url;
|
|
}
|
|
|
|
if (obj.timestamps && typeof obj.timestamps === 'object') {
|
|
const tsObj = obj.timestamps as Record<string, unknown>;
|
|
const ts: ActivityTimestamps = {};
|
|
if (typeof tsObj.start === 'number' && tsObj.start >= 0 && tsObj.start <= MAX_TIMESTAMP) ts.start = tsObj.start;
|
|
if (typeof tsObj.end === 'number' && tsObj.end >= 0 && tsObj.end <= MAX_TIMESTAMP) ts.end = tsObj.end;
|
|
if (ts.start !== undefined || ts.end !== undefined) activity.timestamps = ts;
|
|
}
|
|
|
|
if (obj.assets && typeof obj.assets === 'object') {
|
|
const aObj = obj.assets as Record<string, unknown>;
|
|
const assets: ActivityAssets = {};
|
|
if (typeof aObj.largeImage === 'string' && aObj.largeImage.length <= ACTIVITY_LIMITS.MAX_URL_LENGTH) assets.largeImage = aObj.largeImage;
|
|
if (typeof aObj.largeText === 'string' && aObj.largeText.length <= ACTIVITY_LIMITS.MAX_ASSET_TEXT_LENGTH) assets.largeText = aObj.largeText;
|
|
if (typeof aObj.smallImage === 'string' && aObj.smallImage.length <= ACTIVITY_LIMITS.MAX_URL_LENGTH) assets.smallImage = aObj.smallImage;
|
|
if (typeof aObj.smallText === 'string' && aObj.smallText.length <= ACTIVITY_LIMITS.MAX_ASSET_TEXT_LENGTH) assets.smallText = aObj.smallText;
|
|
if (Object.keys(assets).length > 0) activity.assets = assets;
|
|
}
|
|
|
|
validated.push(activity);
|
|
}
|
|
return validated;
|
|
}
|
|
|
|
function handlePresenceUpdate(event: Record<string, unknown>, userId: string): void {
|
|
const status = event.status as string;
|
|
|
|
if (!status || !['online', 'idle', 'dnd'].includes(status)) {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: 'Status must be "online", "idle", or "dnd"' });
|
|
return;
|
|
}
|
|
|
|
const db = getDb();
|
|
db.update(schema.users).set({ status }).where(eq(schema.users.id, userId)).run();
|
|
|
|
connectionManager.setUserStatus(userId, status);
|
|
const activities = connectionManager.getUserActivities(userId);
|
|
|
|
// Broadcast to all spaces user is in
|
|
const userSpaces = connectionManager.getUserSpaces(userId);
|
|
const payload = {
|
|
type: 'presence_update' as const,
|
|
userId,
|
|
status,
|
|
...(activities.length > 0 ? { activities } : {}),
|
|
};
|
|
for (const spaceId of userSpaces) {
|
|
connectionManager.sendToSpace(spaceId, payload, userId);
|
|
}
|
|
|
|
// Also send to self (other tabs)
|
|
connectionManager.sendToUser(userId, payload);
|
|
}
|
|
|
|
function handleActivityUpdate(event: Record<string, unknown>, userId: string): void {
|
|
if (!connectionManager.getUserShowActivity(userId)) return;
|
|
if (!connectionManager.checkActivityRateLimit(userId)) {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: 'Activity update rate limited' });
|
|
return;
|
|
}
|
|
|
|
const activities = validateActivities(event.activities);
|
|
if (!activities) {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: 'Invalid activity payload' });
|
|
return;
|
|
}
|
|
|
|
connectionManager.setUserActivities(userId, activities);
|
|
const status = connectionManager.getUserStatus(userId);
|
|
|
|
const userSpaces = connectionManager.getUserSpaces(userId);
|
|
const payload = { type: 'presence_update' as const, userId, status, activities };
|
|
for (const spaceId of userSpaces) {
|
|
connectionManager.sendToSpace(spaceId, payload, userId);
|
|
}
|
|
connectionManager.sendToUser(userId, payload);
|
|
}
|
|
|
|
// ─── Voice Handlers (Unified Room API) ─────────────────────────────────────
|
|
|
|
/** Helper: broadcast a voice leave and auto-end empty DM calls. */
|
|
function broadcastRoomLeave(roomId: string, room: VoiceRoom, userId: string): void {
|
|
if (room.roomType === 'space') {
|
|
const meta = room.metadata as SpaceRoomMeta;
|
|
connectionManager.sendToSpace(meta.spaceId, {
|
|
type: 'voice_state_update',
|
|
channelId: roomId,
|
|
userId,
|
|
action: 'leave',
|
|
});
|
|
} else {
|
|
connectionManager.sendToDmMembers(roomId, {
|
|
type: 'voice_state_update',
|
|
channelId: roomId,
|
|
userId,
|
|
action: 'leave',
|
|
});
|
|
// Auto-end call if DM room is now empty and was active
|
|
const updatedRoom = connectionManager.getRoom(roomId);
|
|
if (updatedRoom && updatedRoom.participants.size === 0 && (updatedRoom.metadata as DmRoomMeta).state === 'active') {
|
|
connectionManager.destroyRoom(roomId);
|
|
connectionManager.sendToDmMembers(roomId, {
|
|
type: 'dm_call_ended',
|
|
dmChannelId: roomId,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
function handleVoiceJoin(event: Record<string, unknown>, userId: string, ws: WebSocket): void {
|
|
const channelId = event.channelId as string;
|
|
|
|
if (!channelId || typeof channelId !== 'string') {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: 'channelId is required' });
|
|
return;
|
|
}
|
|
|
|
const spaceId = getChannelSpaceId(channelId);
|
|
if (!spaceId) {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: 'Channel not found' });
|
|
return;
|
|
}
|
|
|
|
if (!hasPermission(userId, spaceId, PermissionBits.CONNECT, channelId)) {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: 'Missing CONNECT permission' });
|
|
return;
|
|
}
|
|
|
|
// ── Device switch guardrail ──
|
|
// If this user already has a voice session on a different socket,
|
|
// notify that socket to tear down its LiveKit connection.
|
|
{
|
|
const oldVoiceWs = connectionManager.getVoiceWs(userId);
|
|
if (oldVoiceWs && oldVoiceWs !== ws) {
|
|
const oldRoom = connectionManager.getUserRoom(userId);
|
|
connectionManager.sendToWs(oldVoiceWs, {
|
|
type: 'voice_disconnected',
|
|
userId,
|
|
channelId: oldRoom?.roomId ?? channelId,
|
|
reason: 'displaced',
|
|
});
|
|
}
|
|
connectionManager.setVoiceWs(userId, ws);
|
|
}
|
|
|
|
// If the user is already in this exact room (e.g. WS reconnect re-registration),
|
|
// skip the leave+join broadcast to avoid visual flicker for other users.
|
|
const currentRoom = connectionManager.getUserRoom(userId);
|
|
if (currentRoom && currentRoom.roomId === channelId) {
|
|
const status = connectionManager.getVoiceUserStatus(userId);
|
|
if (status) {
|
|
connectionManager.sendToRoom(channelId, {
|
|
type: 'voice_status_update',
|
|
userId,
|
|
channelId,
|
|
isMuted: status.isMuted,
|
|
isDeafened: status.isDeafened,
|
|
isCameraOn: status.isCameraOn,
|
|
isScreenSharing: status.isScreenSharing,
|
|
});
|
|
}
|
|
|
|
// Re-broadcast persistent restrictions (covers page reload while in voice)
|
|
const db = getDb();
|
|
const restrictions = db.select()
|
|
.from(schema.voiceRestrictions)
|
|
.where(and(
|
|
eq(schema.voiceRestrictions.spaceId, spaceId),
|
|
eq(schema.voiceRestrictions.userId, userId),
|
|
))
|
|
.all();
|
|
for (const r of restrictions) {
|
|
if (r.restrictionType === 'mute') {
|
|
connectionManager.setSpaceMuted(spaceId, userId, true);
|
|
connectionManager.sendToUser(userId, {
|
|
type: 'voice_space_muted',
|
|
userId,
|
|
channelId,
|
|
spaceId,
|
|
muted: true,
|
|
});
|
|
} else if (r.restrictionType === 'deafen') {
|
|
connectionManager.setSpaceDeafened(spaceId, userId, true);
|
|
connectionManager.sendToUser(userId, {
|
|
type: 'voice_space_deafened',
|
|
userId,
|
|
channelId,
|
|
spaceId,
|
|
deafened: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Re-check permission mute on re-registration
|
|
{
|
|
const perms = computePermissions(userId, spaceId, channelId);
|
|
const canSpeak = (perms & PermissionBits.SPEAK) !== 0n || (perms & PermissionBits.ADMINISTRATOR) !== 0n;
|
|
const shouldPermMute = !canSpeak;
|
|
connectionManager.setPermissionMuted(spaceId, userId, shouldPermMute);
|
|
if (shouldPermMute) {
|
|
connectionManager.sendToUser(userId, {
|
|
type: 'voice_permission_muted',
|
|
userId,
|
|
spaceId,
|
|
muted: true,
|
|
});
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Leave current room (space OR DM)
|
|
const left = connectionManager.leaveCurrentRoom(userId);
|
|
if (left) {
|
|
broadcastRoomLeave(left.roomId, left.room, userId);
|
|
|
|
// Notify all of the user's tabs so the displaced tab tears down LiveKit
|
|
connectionManager.sendToUser(userId, {
|
|
type: 'voice_disconnected',
|
|
userId,
|
|
channelId: left.roomId,
|
|
reason: 'displaced',
|
|
});
|
|
}
|
|
|
|
// Cancel any ringing DM rooms where this user is the caller
|
|
// (edge case: user starts DM call then joins server voice before anyone accepts)
|
|
for (const [roomId, room] of connectionManager.getAllRooms()) {
|
|
if (room.roomType === 'dm') {
|
|
const meta = room.metadata as DmRoomMeta;
|
|
if (meta.state === 'ringing' && meta.callerId === userId) {
|
|
connectionManager.destroyRoom(roomId);
|
|
connectionManager.sendToDmMembers(roomId, {
|
|
type: 'dm_call_ended',
|
|
dmChannelId: roomId,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Lazy-create space room
|
|
connectionManager.createRoom(channelId, 'space', { type: 'space', spaceId });
|
|
|
|
// Join room
|
|
connectionManager.joinRoom(channelId, userId);
|
|
|
|
// Broadcast join
|
|
connectionManager.sendToRoom(channelId, {
|
|
type: 'voice_state_update',
|
|
channelId,
|
|
userId,
|
|
action: 'join',
|
|
});
|
|
|
|
// Also broadcast current voice status if it exists (persisted during moves)
|
|
const status = connectionManager.getVoiceUserStatus(userId);
|
|
if (status) {
|
|
connectionManager.sendToRoom(channelId, {
|
|
type: 'voice_status_update',
|
|
userId,
|
|
channelId,
|
|
isMuted: status.isMuted,
|
|
isDeafened: status.isDeafened,
|
|
isCameraOn: status.isCameraOn,
|
|
isScreenSharing: status.isScreenSharing,
|
|
});
|
|
}
|
|
|
|
// Load persistent voice restrictions for this user in this space
|
|
const db = getDb();
|
|
const restrictions = db.select()
|
|
.from(schema.voiceRestrictions)
|
|
.where(and(
|
|
eq(schema.voiceRestrictions.spaceId, spaceId),
|
|
eq(schema.voiceRestrictions.userId, userId),
|
|
))
|
|
.all();
|
|
|
|
for (const r of restrictions) {
|
|
if (r.restrictionType === 'mute') {
|
|
connectionManager.setSpaceMuted(spaceId, userId, true);
|
|
connectionManager.sendToSpace(spaceId, {
|
|
type: 'voice_space_muted',
|
|
userId,
|
|
channelId,
|
|
spaceId,
|
|
muted: true,
|
|
});
|
|
} else if (r.restrictionType === 'deafen') {
|
|
connectionManager.setSpaceDeafened(spaceId, userId, true);
|
|
connectionManager.sendToSpace(spaceId, {
|
|
type: 'voice_space_deafened',
|
|
userId,
|
|
channelId,
|
|
spaceId,
|
|
deafened: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Check SPEAK permission and apply permission mute if needed
|
|
{
|
|
const perms = computePermissions(userId, spaceId, channelId);
|
|
const canSpeak = (perms & PermissionBits.SPEAK) !== 0n || (perms & PermissionBits.ADMINISTRATOR) !== 0n;
|
|
if (!canSpeak) {
|
|
connectionManager.setPermissionMuted(spaceId, userId, true);
|
|
connectionManager.sendToSpace(spaceId, {
|
|
type: 'voice_permission_muted',
|
|
userId,
|
|
spaceId,
|
|
muted: true,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
function handleVoiceLeave(userId: string): void {
|
|
connectionManager.clearVoiceWs(userId);
|
|
const left = connectionManager.leaveCurrentRoom(userId);
|
|
if (left) {
|
|
broadcastRoomLeave(left.roomId, left.room, userId);
|
|
}
|
|
connectionManager.clearVoiceUserStatus(userId);
|
|
}
|
|
|
|
function handleVoiceStatus(event: Record<string, unknown>, userId: string): void {
|
|
const isMuted = event.isMuted === true;
|
|
const isDeafened = event.isDeafened === true;
|
|
const isCameraOn = event.isCameraOn === true;
|
|
const isScreenSharing = event.isScreenSharing === true;
|
|
|
|
// BUG FIX: uses unified getUserRoom() instead of server-only getUserVoiceChannel()
|
|
// This now works for both server channels AND DM calls.
|
|
const userRoom = connectionManager.getUserRoom(userId);
|
|
if (!userRoom) return;
|
|
|
|
let isSpaceMuted = false;
|
|
let isSpaceDeafened = false;
|
|
let isPermMuted = false;
|
|
if (userRoom.room.roomType === 'space') {
|
|
const meta = userRoom.room.metadata as SpaceRoomMeta;
|
|
isSpaceMuted = connectionManager.isSpaceMuted(meta.spaceId, userId);
|
|
isSpaceDeafened = connectionManager.isSpaceDeafened(meta.spaceId, userId);
|
|
isPermMuted = connectionManager.isPermissionMuted(meta.spaceId, userId);
|
|
}
|
|
|
|
// Server-side enforcement: prevent clients from bypassing server mute/deafen/permission mute
|
|
const effectiveMuted = (isSpaceMuted || isPermMuted) ? true : isMuted;
|
|
const effectiveDeafened = isSpaceDeafened ? true : isDeafened;
|
|
|
|
connectionManager.setVoiceUserStatus(userId, effectiveMuted, effectiveDeafened, isCameraOn, isScreenSharing);
|
|
|
|
// sendToRoom routes to sendToSpace for space rooms, sendToDmMembers for DM rooms
|
|
connectionManager.sendToRoom(userRoom.roomId, {
|
|
type: 'voice_status_update',
|
|
userId,
|
|
channelId: userRoom.roomId,
|
|
isMuted: effectiveMuted,
|
|
isDeafened: effectiveDeafened,
|
|
isCameraOn,
|
|
isScreenSharing,
|
|
});
|
|
}
|
|
|
|
// ─── DM Message Handlers ───────────────────────────────────────────────────
|
|
|
|
function handleDmMessageCreate(event: Record<string, unknown>, userId: string): void {
|
|
const dmChannelId = event.dmChannelId as string;
|
|
const content = event.content as string | undefined;
|
|
const attachmentIds = event.attachments as string[] | undefined;
|
|
const replyToId = event.replyToId as string | undefined;
|
|
|
|
if (!dmChannelId || typeof dmChannelId !== 'string') {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: 'dmChannelId is required' });
|
|
return;
|
|
}
|
|
|
|
const hasContent = content && typeof content === 'string' && content.trim().length > 0;
|
|
const hasAttachments = attachmentIds && Array.isArray(attachmentIds) && attachmentIds.length > 0;
|
|
|
|
if (!hasContent && !hasAttachments) {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: 'Message must have content or attachments' });
|
|
return;
|
|
}
|
|
|
|
if (hasContent && content!.length > MAX_MESSAGE_LENGTH) {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: `Message content must be ${MAX_MESSAGE_LENGTH} characters or less` });
|
|
return;
|
|
}
|
|
|
|
if (!isDmMember(dmChannelId, userId)) {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: 'Not a member of this DM channel' });
|
|
return;
|
|
}
|
|
|
|
const db = getDb();
|
|
|
|
// Verify attachment ownership before linking
|
|
if (hasAttachments) {
|
|
for (const attId of attachmentIds) {
|
|
const att = db.select().from(schema.attachments).where(eq(schema.attachments.id, attId)).get();
|
|
if (!att || att.messageId || att.dmMessageId) {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: 'Invalid or already-used attachment' });
|
|
return;
|
|
}
|
|
if (att.uploaderId && att.uploaderId !== userId) {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: 'You do not own this attachment' });
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
const messageId = generateSnowflake();
|
|
const now = Date.now();
|
|
|
|
db.insert(schema.dmMessages).values({
|
|
id: messageId,
|
|
dmChannelId,
|
|
userId,
|
|
replyToId: replyToId || null,
|
|
content: hasContent ? content.trim() : null,
|
|
createdAt: now,
|
|
}).run();
|
|
|
|
// Link attachments to this DM message
|
|
if (hasAttachments) {
|
|
for (const attId of attachmentIds) {
|
|
db.update(schema.attachments)
|
|
.set({ dmMessageId: messageId })
|
|
.where(eq(schema.attachments.id, attId))
|
|
.run();
|
|
}
|
|
}
|
|
|
|
const dmMessage = getDmMessageWithUser(messageId);
|
|
if (!dmMessage) return;
|
|
|
|
// Broadcast to all DM members (including those who closed the channel)
|
|
broadcastDmMessage(dmChannelId, dmMessage);
|
|
|
|
// Federation: queue for relay
|
|
queueDmRelay(dmMessage, dmChannelId, 'create');
|
|
|
|
// Clear any pending typing timeout for this user+channel
|
|
const typingKey = `dm:${userId}:${dmChannelId}`;
|
|
const existingTimeout = typingTimeouts.get(typingKey);
|
|
if (existingTimeout) {
|
|
clearTimeout(existingTimeout);
|
|
typingTimeouts.delete(typingKey);
|
|
}
|
|
|
|
// Resolve embeds asynchronously
|
|
setImmediate(() => {
|
|
resolveEmbeds(messageId, hasContent ? content!.trim() : null, dmChannelId, true, null).catch(() => {});
|
|
});
|
|
}
|
|
|
|
function handleDmTypingStart(event: Record<string, unknown>, userId: string, username: string): void {
|
|
const dmChannelId = event.dmChannelId as string;
|
|
|
|
if (!dmChannelId || typeof dmChannelId !== 'string') return;
|
|
|
|
if (!isDmMember(dmChannelId, userId)) return;
|
|
|
|
const key = `dm:${userId}:${dmChannelId}`;
|
|
const existing = typingTimeouts.get(key);
|
|
if (existing) {
|
|
clearTimeout(existing);
|
|
}
|
|
|
|
// Send to all other DM members
|
|
const db = getDb();
|
|
const dmMembers = db.select()
|
|
.from(schema.dmMembers)
|
|
.where(eq(schema.dmMembers.dmChannelId, dmChannelId))
|
|
.all();
|
|
|
|
for (const member of dmMembers) {
|
|
if (member.userId !== userId) {
|
|
connectionManager.sendToUser(member.userId, {
|
|
type: 'dm_typing',
|
|
dmChannelId,
|
|
userId,
|
|
username,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Relay typing indicator to remote peers (fire-and-forget)
|
|
sendTypingRelay(dmChannelId, 'dm_typing_start', userId);
|
|
|
|
const timeout = setTimeout(() => {
|
|
typingTimeouts.delete(key);
|
|
}, 5000);
|
|
typingTimeouts.set(key, timeout);
|
|
}
|
|
|
|
function handleDmMessageEdit(event: Record<string, unknown>, userId: string): void {
|
|
const messageId = event.messageId as string;
|
|
const content = event.content as string;
|
|
|
|
if (!messageId || typeof messageId !== 'string') {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: 'messageId is required' });
|
|
return;
|
|
}
|
|
|
|
if (!content || typeof content !== 'string' || content.trim().length === 0) {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: 'content is required' });
|
|
return;
|
|
}
|
|
|
|
if (content.length > MAX_MESSAGE_LENGTH) {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: `Message content must be ${MAX_MESSAGE_LENGTH} characters or less` });
|
|
return;
|
|
}
|
|
|
|
const db = getDb();
|
|
const msg = db.select().from(schema.dmMessages).where(eq(schema.dmMessages.id, messageId)).get();
|
|
if (!msg) {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: 'Message not found' });
|
|
return;
|
|
}
|
|
|
|
if (msg.userId !== userId) {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: 'You can only edit your own messages' });
|
|
return;
|
|
}
|
|
|
|
const now = Date.now();
|
|
db.update(schema.dmMessages)
|
|
.set({ content: content.trim(), editedAt: now })
|
|
.where(eq(schema.dmMessages.id, messageId))
|
|
.run();
|
|
|
|
// Delete old embeds synchronously so the broadcast reflects the edit
|
|
db.delete(schema.embeds).where(eq(schema.embeds.dmMessageId, messageId)).run();
|
|
|
|
const updated = getDmMessageWithUser(messageId);
|
|
if (!updated) return;
|
|
|
|
const dmMembers = db.select()
|
|
.from(schema.dmMembers)
|
|
.where(eq(schema.dmMembers.dmChannelId, msg.dmChannelId))
|
|
.all();
|
|
|
|
for (const member of dmMembers) {
|
|
connectionManager.sendToUser(member.userId, {
|
|
type: 'dm_message_updated',
|
|
message: updated,
|
|
});
|
|
}
|
|
|
|
// Federation: queue for relay
|
|
queueDmRelay(updated, msg.dmChannelId, 'update');
|
|
|
|
// Resolve new embeds asynchronously (old ones already deleted above)
|
|
setImmediate(() => {
|
|
resolveEmbeds(messageId, content.trim(), msg.dmChannelId, true, null).catch(() => {});
|
|
});
|
|
}
|
|
|
|
function handleDmMessageDelete(event: Record<string, unknown>, userId: string): void {
|
|
const messageId = event.messageId as string;
|
|
|
|
if (!messageId || typeof messageId !== 'string') {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: 'messageId is required' });
|
|
return;
|
|
}
|
|
|
|
const db = getDb();
|
|
const msg = db.select().from(schema.dmMessages).where(eq(schema.dmMessages.id, messageId)).get();
|
|
if (!msg) {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: 'Message not found' });
|
|
return;
|
|
}
|
|
|
|
if (msg.userId !== userId) {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: 'You can only delete your own messages' });
|
|
return;
|
|
}
|
|
|
|
// Collect attachment filenames before deletion
|
|
const dmAttachmentRows = db.select({ filename: schema.attachments.filename })
|
|
.from(schema.attachments).where(eq(schema.attachments.dmMessageId, messageId)).all();
|
|
|
|
// Delete attachments, reactions, and message atomically
|
|
db.transaction((tx) => {
|
|
tx.delete(schema.attachments)
|
|
.where(eq(schema.attachments.dmMessageId, messageId))
|
|
.run();
|
|
tx.delete(schema.dmReactions)
|
|
.where(eq(schema.dmReactions.dmMessageId, messageId))
|
|
.run();
|
|
tx.delete(schema.dmMessages)
|
|
.where(eq(schema.dmMessages.id, messageId))
|
|
.run();
|
|
});
|
|
|
|
// Clean up files from disk
|
|
deleteAttachmentFiles(dmAttachmentRows);
|
|
|
|
const dmMembers = db.select()
|
|
.from(schema.dmMembers)
|
|
.where(eq(schema.dmMembers.dmChannelId, msg.dmChannelId))
|
|
.all();
|
|
|
|
for (const member of dmMembers) {
|
|
connectionManager.sendToUser(member.userId, {
|
|
type: 'dm_message_deleted',
|
|
messageId,
|
|
dmChannelId: msg.dmChannelId,
|
|
});
|
|
}
|
|
|
|
// Federation: log mutation and queue for relay
|
|
appendMutationLog(messageId, msg.dmChannelId, 'delete');
|
|
const targetOrigins = getGroupDmTargetOrigins(msg.dmChannelId);
|
|
queueOutboxEvent(messageId, msg.dmChannelId, 'delete', JSON.stringify({ deleted: true }), targetOrigins);
|
|
}
|
|
|
|
// ─── Reaction Handlers ─────────────────────────────────────────────────────
|
|
|
|
function handleReactionAdd(event: Record<string, unknown>, userId: string, isFederated: boolean): void {
|
|
const messageId = event.messageId as string;
|
|
const emoji = event.emoji as string;
|
|
|
|
if (!messageId || !emoji) return;
|
|
|
|
const db = getDb();
|
|
|
|
// Try space message first
|
|
const message = db.select().from(schema.messages).where(eq(schema.messages.id, messageId)).get();
|
|
if (message) {
|
|
const spaceId = getChannelSpaceId(message.channelId);
|
|
if (!spaceId || !isMember(spaceId, userId)) return;
|
|
|
|
if (!hasPermission(userId, spaceId, PermissionBits.ADD_REACTIONS, message.channelId)) {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: 'Missing ADD_REACTIONS permission' });
|
|
return;
|
|
}
|
|
|
|
const reactionId = generateSnowflake();
|
|
const now = Date.now();
|
|
try {
|
|
db.insert(schema.reactions).values({
|
|
id: reactionId,
|
|
messageId,
|
|
userId,
|
|
emoji,
|
|
createdAt: now,
|
|
}).run();
|
|
|
|
// Include user object so remote clients can use isSelf() for identity resolution
|
|
const reactionUser = db.select().from(schema.users).where(eq(schema.users.id, userId)).get();
|
|
const userObj = reactionUser ? sanitizeUser(reactionUser) : undefined;
|
|
|
|
connectionManager.sendToChannel(spaceId, message.channelId, {
|
|
type: 'reaction_added',
|
|
messageId,
|
|
reaction: { id: reactionId, messageId, userId, emoji, createdAt: now, user: userObj },
|
|
});
|
|
} catch (err) {
|
|
// Unique constraint violation (already reacted)
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Fall through to DM message
|
|
if (isFederated) return;
|
|
const dmMsg = db.select().from(schema.dmMessages).where(eq(schema.dmMessages.id, messageId)).get();
|
|
if (!dmMsg || !isDmMember(dmMsg.dmChannelId, userId)) return;
|
|
|
|
const reactionId = generateSnowflake();
|
|
const now = Date.now();
|
|
try {
|
|
db.insert(schema.dmReactions).values({
|
|
id: reactionId,
|
|
dmMessageId: messageId,
|
|
userId,
|
|
emoji,
|
|
createdAt: now,
|
|
}).run();
|
|
|
|
// Include user object so remote clients can use isSelf() for identity resolution
|
|
const reactionUser = db.select().from(schema.users).where(eq(schema.users.id, userId)).get();
|
|
const userObj = reactionUser ? sanitizeUser(reactionUser) : undefined;
|
|
|
|
connectionManager.sendToDmMembers(dmMsg.dmChannelId, {
|
|
type: 'reaction_added',
|
|
messageId,
|
|
reaction: { id: reactionId, messageId, userId, emoji, createdAt: now, user: userObj },
|
|
});
|
|
|
|
// Federation: log reaction mutation and queue for relay
|
|
const canonicalMessageId = dmMsg.sourceMessageId || messageId;
|
|
const messageHomeInstance = dmMsg.sourceInstance || getOurOrigin();
|
|
appendMutationLog(messageId, dmMsg.dmChannelId, 'reaction_add', JSON.stringify({
|
|
userId,
|
|
homeUserId: reactionUser?.homeUserId || userId,
|
|
homeInstance: reactionUser?.homeInstance || getOurOrigin(),
|
|
emoji,
|
|
createdAt: now,
|
|
}));
|
|
const reactionAddTargetOrigins = getGroupDmTargetOrigins(dmMsg.dmChannelId);
|
|
queueOutboxEvent(reactionId, dmMsg.dmChannelId, 'reaction_add', JSON.stringify({
|
|
reaction: {
|
|
messageId: canonicalMessageId,
|
|
messageHomeInstance,
|
|
userId,
|
|
homeUserId: reactionUser?.homeUserId || userId,
|
|
homeInstance: reactionUser?.homeInstance || getOurOrigin(),
|
|
emoji,
|
|
createdAt: now,
|
|
},
|
|
}), reactionAddTargetOrigins);
|
|
} catch (err) {
|
|
// Unique constraint violation (already reacted)
|
|
}
|
|
}
|
|
|
|
function handleReactionRemove(event: Record<string, unknown>, userId: string, isFederated: boolean): void {
|
|
const messageId = event.messageId as string;
|
|
const emoji = event.emoji as string;
|
|
|
|
if (!messageId || !emoji) return;
|
|
|
|
const db = getDb();
|
|
|
|
// Try space message first
|
|
const message = db.select().from(schema.messages).where(eq(schema.messages.id, messageId)).get();
|
|
if (message) {
|
|
const spaceId = getChannelSpaceId(message.channelId);
|
|
if (!spaceId || !isMember(spaceId, userId)) return;
|
|
|
|
const result = db.delete(schema.reactions)
|
|
.where(and(
|
|
eq(schema.reactions.messageId, messageId),
|
|
eq(schema.reactions.userId, userId),
|
|
eq(schema.reactions.emoji, emoji)
|
|
))
|
|
.run();
|
|
|
|
if (result.changes > 0) {
|
|
connectionManager.sendToChannel(spaceId, message.channelId, {
|
|
type: 'reaction_removed',
|
|
messageId,
|
|
userId,
|
|
emoji,
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Fall through to DM message
|
|
if (isFederated) return;
|
|
const dmMsg = db.select().from(schema.dmMessages).where(eq(schema.dmMessages.id, messageId)).get();
|
|
if (!dmMsg || !isDmMember(dmMsg.dmChannelId, userId)) return;
|
|
|
|
const result = db.delete(schema.dmReactions)
|
|
.where(and(
|
|
eq(schema.dmReactions.dmMessageId, messageId),
|
|
eq(schema.dmReactions.userId, userId),
|
|
eq(schema.dmReactions.emoji, emoji)
|
|
))
|
|
.run();
|
|
|
|
if (result.changes > 0) {
|
|
connectionManager.sendToDmMembers(dmMsg.dmChannelId, {
|
|
type: 'reaction_removed',
|
|
messageId,
|
|
userId,
|
|
emoji,
|
|
});
|
|
|
|
// Federation: log reaction removal and queue for relay
|
|
const removingUser = db.select().from(schema.users).where(eq(schema.users.id, userId)).get();
|
|
const canonicalMessageId = dmMsg.sourceMessageId || messageId;
|
|
const messageHomeInstance = dmMsg.sourceInstance || getOurOrigin();
|
|
appendMutationLog(messageId, dmMsg.dmChannelId, 'reaction_remove', JSON.stringify({
|
|
userId,
|
|
homeUserId: removingUser?.homeUserId || userId,
|
|
homeInstance: removingUser?.homeInstance || getOurOrigin(),
|
|
emoji,
|
|
}));
|
|
const reactionRemoveTargetOrigins = getGroupDmTargetOrigins(dmMsg.dmChannelId);
|
|
queueOutboxEvent(
|
|
`${messageId}:${userId}:${emoji}`,
|
|
dmMsg.dmChannelId,
|
|
'reaction_remove',
|
|
JSON.stringify({
|
|
reaction: {
|
|
messageId: canonicalMessageId,
|
|
messageHomeInstance,
|
|
userId,
|
|
homeUserId: removingUser?.homeUserId || userId,
|
|
homeInstance: removingUser?.homeInstance || getOurOrigin(),
|
|
emoji,
|
|
},
|
|
}),
|
|
reactionRemoveTargetOrigins,
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─── Read State Handler ────────────────────────────────────────────────────
|
|
|
|
function handleChannelAck(event: Record<string, unknown>, userId: string, isFederated: boolean): void {
|
|
const channelId = event.channelId as string;
|
|
const messageId = event.messageId as string;
|
|
if (!channelId || !messageId) return;
|
|
// Validate messageId is a valid snowflake (numeric string) — reject temp/garbage IDs
|
|
if (!/^\d+$/.test(messageId)) return;
|
|
|
|
// Validate channel membership — reject acks for channels the user doesn't belong to
|
|
const spaceId = getChannelSpaceId(channelId);
|
|
if (spaceId) {
|
|
if (!isMember(spaceId, userId)) return;
|
|
} else {
|
|
if (!isDmMember(channelId, userId)) return;
|
|
}
|
|
|
|
const db = getDb();
|
|
|
|
const existing = db.select()
|
|
.from(schema.readStates)
|
|
.where(and(
|
|
eq(schema.readStates.userId, userId),
|
|
eq(schema.readStates.channelId, channelId),
|
|
))
|
|
.get();
|
|
|
|
const now = Date.now();
|
|
|
|
if (existing) {
|
|
// Only update if the new messageId is newer (larger snowflake)
|
|
if (BigInt(messageId) > BigInt(existing.lastReadMessageId)) {
|
|
db.update(schema.readStates)
|
|
.set({ lastReadMessageId: messageId, updatedAt: now })
|
|
.where(and(
|
|
eq(schema.readStates.userId, userId),
|
|
eq(schema.readStates.channelId, channelId),
|
|
))
|
|
.run();
|
|
}
|
|
} else {
|
|
db.insert(schema.readStates).values({
|
|
userId,
|
|
channelId,
|
|
lastReadMessageId: messageId,
|
|
updatedAt: now,
|
|
}).run();
|
|
}
|
|
|
|
// Echo ack back to all of this user's connections (multi-tab sync)
|
|
connectionManager.sendToUser(userId, {
|
|
type: 'channel_ack',
|
|
channelId,
|
|
messageId,
|
|
});
|
|
|
|
// Relay read state to federated peers for cross-instance sync
|
|
queueReadStateRelay(channelId, messageId, userId);
|
|
}
|
|
|
|
function handleMarkUnread(event: Record<string, unknown>, userId: string, isFederated: boolean): void {
|
|
const channelId = event.channelId as string;
|
|
const messageId = event.messageId as string;
|
|
if (!channelId || !messageId) return;
|
|
if (!/^\d+$/.test(messageId)) return;
|
|
|
|
// Validate channel membership
|
|
const spaceId = getChannelSpaceId(channelId);
|
|
if (spaceId) {
|
|
if (!isMember(spaceId, userId)) return;
|
|
} else {
|
|
if (!isDmMember(channelId, userId)) return;
|
|
}
|
|
|
|
const db = getDb();
|
|
const now = Date.now();
|
|
|
|
if (messageId === '0') {
|
|
// '0' sentinel: delete the read state entirely → channel appears fully unread
|
|
db.delete(schema.readStates)
|
|
.where(and(
|
|
eq(schema.readStates.userId, userId),
|
|
eq(schema.readStates.channelId, channelId),
|
|
))
|
|
.run();
|
|
} else {
|
|
// Set read state to the specified message (allows backward writes)
|
|
const existing = db.select()
|
|
.from(schema.readStates)
|
|
.where(and(
|
|
eq(schema.readStates.userId, userId),
|
|
eq(schema.readStates.channelId, channelId),
|
|
))
|
|
.get();
|
|
|
|
if (existing) {
|
|
db.update(schema.readStates)
|
|
.set({ lastReadMessageId: messageId, updatedAt: now })
|
|
.where(and(
|
|
eq(schema.readStates.userId, userId),
|
|
eq(schema.readStates.channelId, channelId),
|
|
))
|
|
.run();
|
|
} else {
|
|
db.insert(schema.readStates).values({
|
|
userId,
|
|
channelId,
|
|
lastReadMessageId: messageId,
|
|
updatedAt: now,
|
|
}).run();
|
|
}
|
|
}
|
|
|
|
// Broadcast to all of this user's connections (multi-tab sync)
|
|
connectionManager.sendToUser(userId, {
|
|
type: 'mark_unread',
|
|
channelId,
|
|
messageId,
|
|
});
|
|
}
|
|
|
|
// ─── DM Call Handlers (Unified Room API) ───────────────────────────────────
|
|
|
|
function handleDmCallStart(event: Record<string, unknown>, userId: string, username: string, ws: WebSocket): void {
|
|
const dmChannelId = event.dmChannelId as string;
|
|
if (!dmChannelId || typeof dmChannelId !== 'string') {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: 'dmChannelId is required' });
|
|
return;
|
|
}
|
|
|
|
if (!isDmMember(dmChannelId, userId)) {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: 'You are not a member of this DM channel' });
|
|
return;
|
|
}
|
|
|
|
// Leave current room if in one
|
|
const left = connectionManager.leaveCurrentRoom(userId);
|
|
if (left) {
|
|
broadcastRoomLeave(left.roomId, left.room, userId);
|
|
}
|
|
connectionManager.clearVoiceUserStatus(userId);
|
|
|
|
// Cancel any other ringing rooms started by this user
|
|
for (const [roomId, room] of connectionManager.getAllRooms()) {
|
|
if (room.roomType === 'dm' && roomId !== dmChannelId) {
|
|
const meta = room.metadata as DmRoomMeta;
|
|
if (meta.state === 'ringing' && meta.callerId === userId) {
|
|
connectionManager.destroyRoom(roomId);
|
|
connectionManager.sendToDmMembers(roomId, {
|
|
type: 'dm_call_ended',
|
|
dmChannelId: roomId,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Create DM room in ringing state (fails if already active)
|
|
const created = connectionManager.createDmRoom(dmChannelId, userId);
|
|
if (!created) {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: 'A call is already active in this DM channel' });
|
|
return;
|
|
}
|
|
|
|
// Bind voice session to this socket so removeConnection can clean up
|
|
// if the caller closes the tab while the call is ringing
|
|
connectionManager.setVoiceWs(userId, ws);
|
|
|
|
// Ring other members
|
|
connectionManager.sendToDmMembers(dmChannelId, {
|
|
type: 'dm_call_incoming',
|
|
dmChannelId,
|
|
callerId: userId,
|
|
callerName: username,
|
|
}, userId);
|
|
|
|
// Federation: notify remote instances (fire-and-forget)
|
|
sendFederatedCallStart(dmChannelId, userId, username)
|
|
.catch(err => console.error('[federation] sendFederatedCallStart error:', err));
|
|
}
|
|
|
|
function handleDmCallAccept(event: Record<string, unknown>, userId: string, ws: WebSocket): void {
|
|
const dmChannelId = event.dmChannelId as string;
|
|
if (!dmChannelId || typeof dmChannelId !== 'string') {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: 'dmChannelId is required' });
|
|
return;
|
|
}
|
|
|
|
if (!isDmMember(dmChannelId, userId)) {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: 'You are not a member of this DM channel' });
|
|
return;
|
|
}
|
|
|
|
const room = connectionManager.getRoom(dmChannelId);
|
|
if (!room || room.roomType !== 'dm') {
|
|
// Check if this is a federated call (this instance is not the host)
|
|
const fedCall = connectionManager.getFederatedCall(dmChannelId);
|
|
if (fedCall) {
|
|
// Transition local state
|
|
connectionManager.activateFederatedCall(dmChannelId);
|
|
|
|
// Broadcast locally
|
|
connectionManager.sendToDmMembers(dmChannelId, {
|
|
type: 'dm_call_accepted',
|
|
dmChannelId,
|
|
});
|
|
|
|
// Send accept to host (fire-and-forget)
|
|
const db = getDb();
|
|
const user = db.select({ homeUserId: schema.users.homeUserId })
|
|
.from(schema.users)
|
|
.where(eq(schema.users.id, userId))
|
|
.get();
|
|
const homeUserId = user?.homeUserId || userId;
|
|
|
|
sendCallRelay(fedCall.federatedCallHost, [{
|
|
eventType: 'dm_call_accept',
|
|
messageId: generateSnowflake(),
|
|
encryptionVersion: 0,
|
|
timestamp: Date.now(),
|
|
federatedId: fedCall.federatedId,
|
|
call: {
|
|
acceptor: { homeUserId, homeInstance: getOurOrigin() },
|
|
},
|
|
}]).catch(err => console.error('[federation] Failed to send dm_call_accept:', err));
|
|
|
|
return;
|
|
}
|
|
|
|
connectionManager.sendToUser(userId, { type: 'error', message: 'No active call in this DM channel' });
|
|
return;
|
|
}
|
|
|
|
const meta = room.metadata as DmRoomMeta;
|
|
|
|
if (meta.state === 'ringing') {
|
|
// First accept — transition ringing→active and join the caller
|
|
connectionManager.activateDmRoom(dmChannelId);
|
|
|
|
// Leave caller's current room if in one
|
|
const callerLeft = connectionManager.leaveCurrentRoom(meta.callerId);
|
|
if (callerLeft) {
|
|
broadcastRoomLeave(callerLeft.roomId, callerLeft.room, meta.callerId);
|
|
}
|
|
|
|
// Join caller into the DM room
|
|
connectionManager.joinRoom(dmChannelId, meta.callerId);
|
|
|
|
// Broadcast voice_state_update join for caller
|
|
connectionManager.sendToDmMembers(dmChannelId, {
|
|
type: 'voice_state_update',
|
|
channelId: dmChannelId,
|
|
userId: meta.callerId,
|
|
action: 'join',
|
|
});
|
|
}
|
|
// If already active, this is a late-join (e.g. 3rd member joining group call).
|
|
// Skip the ringing→active transition and caller join — just join the acceptor below.
|
|
|
|
// Leave acceptor's current room if in one
|
|
const acceptorLeft = connectionManager.leaveCurrentRoom(userId);
|
|
if (acceptorLeft) {
|
|
broadcastRoomLeave(acceptorLeft.roomId, acceptorLeft.room, userId);
|
|
}
|
|
|
|
// Join acceptor into the DM room
|
|
connectionManager.joinRoom(dmChannelId, userId);
|
|
|
|
// Bind voice session to this socket for the acceptor
|
|
connectionManager.setVoiceWs(userId, ws);
|
|
|
|
// Notify all DM members that the call was accepted
|
|
connectionManager.sendToDmMembers(dmChannelId, {
|
|
type: 'dm_call_accepted',
|
|
dmChannelId,
|
|
});
|
|
|
|
// Broadcast voice_state_update join for acceptor
|
|
connectionManager.sendToDmMembers(dmChannelId, {
|
|
type: 'voice_state_update',
|
|
channelId: dmChannelId,
|
|
userId,
|
|
action: 'join',
|
|
});
|
|
|
|
// Federation: notify remote instances that the call was accepted
|
|
sendFederatedCallAccept(dmChannelId, userId)
|
|
.catch(err => console.error('[federation] sendFederatedCallAccept error:', err));
|
|
}
|
|
|
|
function handleDmCallReject(event: Record<string, unknown>, userId: string): void {
|
|
const dmChannelId = event.dmChannelId as string;
|
|
if (!dmChannelId || typeof dmChannelId !== 'string') {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: 'dmChannelId is required' });
|
|
return;
|
|
}
|
|
|
|
if (!isDmMember(dmChannelId, userId)) {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: 'You are not a member of this DM channel' });
|
|
return;
|
|
}
|
|
|
|
const room = connectionManager.getRoom(dmChannelId);
|
|
if (!room) {
|
|
// Check federated call registry
|
|
const fedCall = connectionManager.getFederatedCall(dmChannelId);
|
|
if (fedCall) {
|
|
connectionManager.clearFederatedCall(dmChannelId);
|
|
connectionManager.sendToDmMembers(dmChannelId, {
|
|
type: 'dm_call_rejected',
|
|
dmChannelId,
|
|
});
|
|
|
|
const db = getDb();
|
|
const user = db.select({ homeUserId: schema.users.homeUserId })
|
|
.from(schema.users)
|
|
.where(eq(schema.users.id, userId))
|
|
.get();
|
|
const homeUserId = user?.homeUserId || userId;
|
|
|
|
sendCallRelay(fedCall.federatedCallHost, [{
|
|
eventType: 'dm_call_reject',
|
|
messageId: generateSnowflake(),
|
|
encryptionVersion: 0,
|
|
timestamp: Date.now(),
|
|
federatedId: fedCall.federatedId,
|
|
call: {
|
|
rejector: { homeUserId, homeInstance: getOurOrigin() },
|
|
},
|
|
}]).catch(err => console.error('[federation] Failed to send dm_call_reject:', err));
|
|
|
|
return;
|
|
}
|
|
return; // No active call, silently ignore
|
|
}
|
|
|
|
// Extract caller ID before destroying the room
|
|
const meta = room.metadata as DmRoomMeta;
|
|
|
|
// Clear caller's voice ws binding (set during handleDmCallStart)
|
|
connectionManager.clearVoiceWs(meta.callerId);
|
|
|
|
// Destroy room
|
|
connectionManager.destroyRoom(dmChannelId);
|
|
|
|
// Notify all DM members that the call was rejected
|
|
connectionManager.sendToDmMembers(dmChannelId, {
|
|
type: 'dm_call_rejected',
|
|
dmChannelId,
|
|
});
|
|
|
|
// Federation: notify remote instances the call was rejected (= ended for 1-on-1)
|
|
sendFederatedCallEnd(dmChannelId, userId)
|
|
.catch(err => console.error('[federation] sendFederatedCallEnd error:', err));
|
|
}
|
|
|
|
function handleDmCallEnd(event: Record<string, unknown>, userId: string): void {
|
|
const dmChannelId = event.dmChannelId as string;
|
|
if (!dmChannelId || typeof dmChannelId !== 'string') {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: 'dmChannelId is required' });
|
|
return;
|
|
}
|
|
|
|
if (!isDmMember(dmChannelId, userId)) {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: 'You are not a member of this DM channel' });
|
|
return;
|
|
}
|
|
|
|
const room = connectionManager.getRoom(dmChannelId);
|
|
if (!room) {
|
|
const fedCall = connectionManager.getFederatedCall(dmChannelId);
|
|
if (fedCall) {
|
|
connectionManager.clearFederatedCall(dmChannelId);
|
|
connectionManager.sendToDmMembers(dmChannelId, {
|
|
type: 'dm_call_ended',
|
|
dmChannelId,
|
|
});
|
|
|
|
const db = getDb();
|
|
const user = db.select({ homeUserId: schema.users.homeUserId })
|
|
.from(schema.users)
|
|
.where(eq(schema.users.id, userId))
|
|
.get();
|
|
const homeUserId = user?.homeUserId || userId;
|
|
|
|
sendCallRelay(fedCall.federatedCallHost, [{
|
|
eventType: 'dm_call_end',
|
|
messageId: generateSnowflake(),
|
|
encryptionVersion: 0,
|
|
timestamp: Date.now(),
|
|
federatedId: fedCall.federatedId,
|
|
call: {
|
|
endedBy: { homeUserId, homeInstance: getOurOrigin() },
|
|
},
|
|
}]).catch(err => console.error('[federation] Failed to send dm_call_end:', err));
|
|
|
|
return;
|
|
}
|
|
return; // No active call, silently ignore
|
|
}
|
|
|
|
// Clear voice ws binding for the caller (may not be in participants if still ringing)
|
|
const meta = room.metadata as DmRoomMeta;
|
|
connectionManager.clearVoiceWs(meta.callerId);
|
|
|
|
// Clear voice user states and voice ws for all participants
|
|
for (const participantId of room.participants) {
|
|
connectionManager.clearVoiceUserStatus(participantId);
|
|
connectionManager.clearVoiceWs(participantId);
|
|
}
|
|
|
|
// Destroy room (removes all participants from userToRoom)
|
|
connectionManager.destroyRoom(dmChannelId);
|
|
|
|
// Notify all DM members that the call ended
|
|
connectionManager.sendToDmMembers(dmChannelId, {
|
|
type: 'dm_call_ended',
|
|
dmChannelId,
|
|
});
|
|
|
|
// Federation: notify remote instances the call ended
|
|
sendFederatedCallEnd(dmChannelId, userId)
|
|
.catch(err => console.error('[federation] sendFederatedCallEnd error:', err));
|
|
}
|
|
|
|
/**
|
|
* Send S2S dm_call_start to all remote instances with DM members.
|
|
* Fire-and-forget — if delivery fails, the call still works locally.
|
|
*/
|
|
async function sendFederatedCallStart(
|
|
dmChannelId: string,
|
|
callerId: string,
|
|
callerName: string,
|
|
): Promise<void> {
|
|
const db = getDb();
|
|
|
|
// Look up DM channel for federatedId
|
|
const channel = db.select({
|
|
federatedId: schema.dmChannels.federatedId,
|
|
ownerId: schema.dmChannels.ownerId,
|
|
})
|
|
.from(schema.dmChannels)
|
|
.where(eq(schema.dmChannels.id, dmChannelId))
|
|
.get();
|
|
|
|
if (!channel) return;
|
|
|
|
// Get all DM members with their user records
|
|
const members = db.select({
|
|
userId: schema.dmMembers.userId,
|
|
homeUserId: schema.users.homeUserId,
|
|
homeInstance: schema.users.homeInstance,
|
|
username: schema.users.username,
|
|
displayName: schema.users.displayName,
|
|
})
|
|
.from(schema.dmMembers)
|
|
.innerJoin(schema.users, eq(schema.dmMembers.userId, schema.users.id))
|
|
.where(eq(schema.dmMembers.dmChannelId, dmChannelId))
|
|
.all();
|
|
|
|
const ourOrigin = getOurOrigin();
|
|
|
|
// Filter to remote members
|
|
const remoteMembers = members.filter(m => {
|
|
if (!m.homeInstance) return false;
|
|
const normalized = m.homeInstance.startsWith('http') ? m.homeInstance : `https://${m.homeInstance}`;
|
|
return normalized !== ourOrigin;
|
|
});
|
|
|
|
if (remoteMembers.length === 0) return;
|
|
|
|
// Compute or reuse federatedId
|
|
let federatedId = channel.federatedId;
|
|
if (!federatedId) {
|
|
if (!channel.ownerId) {
|
|
// 1-on-1: compute from homeUserId pair
|
|
const callerMember = members.find(m => m.userId === callerId);
|
|
const otherMember = members.find(m => m.userId !== callerId);
|
|
if (!callerMember || !otherMember) return;
|
|
federatedId = computeFederatedId(
|
|
callerMember.homeUserId || callerMember.userId,
|
|
otherMember.homeUserId || otherMember.userId,
|
|
);
|
|
} else {
|
|
federatedId = crypto.randomUUID();
|
|
}
|
|
// Persist for future use
|
|
db.update(schema.dmChannels)
|
|
.set({ federatedId })
|
|
.where(eq(schema.dmChannels.id, dmChannelId))
|
|
.run();
|
|
}
|
|
|
|
// Check LiveKit configuration
|
|
if (!config.livekit.apiKey || !config.livekit.apiSecret) {
|
|
console.warn('[federation] Cannot start federated call: LiveKit not configured');
|
|
return;
|
|
}
|
|
|
|
// Group remote members by home instance
|
|
const peerGroups = new Map<string, typeof remoteMembers>();
|
|
for (const m of remoteMembers) {
|
|
const origin = m.homeInstance!.startsWith('http') ? m.homeInstance! : `https://${m.homeInstance!}`;
|
|
if (!peerGroups.has(origin)) peerGroups.set(origin, []);
|
|
peerGroups.get(origin)!.push(m);
|
|
}
|
|
|
|
const livekitUrl = `https://${config.domain}/livekit`;
|
|
|
|
for (const [peerOrigin, peerMembers] of peerGroups) {
|
|
// Generate per-user tokens for this peer's members
|
|
const tokens: Record<string, string> = {};
|
|
for (const m of peerMembers) {
|
|
const homeUserId = m.homeUserId || m.userId;
|
|
const name = m.displayName || m.username;
|
|
tokens[homeUserId] = await generateFederatedCallToken(federatedId, homeUserId, name);
|
|
}
|
|
|
|
const result = await sendCallRelay(peerOrigin, [{
|
|
eventType: 'dm_call_start',
|
|
messageId: generateSnowflake(),
|
|
encryptionVersion: 0,
|
|
timestamp: Date.now(),
|
|
federatedId,
|
|
call: {
|
|
livekitUrl,
|
|
tokens,
|
|
caller: {
|
|
homeUserId: members.find(m => m.userId === callerId)?.homeUserId || callerId,
|
|
homeInstance: ourOrigin,
|
|
displayName: callerName,
|
|
},
|
|
},
|
|
}]);
|
|
|
|
if (!result.ok) {
|
|
console.error(`[federation] Failed to send dm_call_start to ${peerOrigin}: ${result.error}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function sendFederatedCallAccept(dmChannelId: string, acceptorUserId: string): Promise<void> {
|
|
const db = getDb();
|
|
const channel = db.select({ federatedId: schema.dmChannels.federatedId })
|
|
.from(schema.dmChannels)
|
|
.where(eq(schema.dmChannels.id, dmChannelId))
|
|
.get();
|
|
if (!channel?.federatedId) return;
|
|
|
|
const members = db.select({ homeInstance: schema.users.homeInstance })
|
|
.from(schema.dmMembers)
|
|
.innerJoin(schema.users, eq(schema.dmMembers.userId, schema.users.id))
|
|
.where(eq(schema.dmMembers.dmChannelId, dmChannelId))
|
|
.all();
|
|
|
|
const ourOrigin = getOurOrigin();
|
|
const targets = new Set<string>();
|
|
for (const m of members) {
|
|
if (m.homeInstance) {
|
|
const normalized = m.homeInstance.startsWith('http') ? m.homeInstance : `https://${m.homeInstance}`;
|
|
if (normalized !== ourOrigin) targets.add(normalized);
|
|
}
|
|
}
|
|
if (targets.size === 0) return;
|
|
|
|
const user = db.select({ homeUserId: schema.users.homeUserId })
|
|
.from(schema.users)
|
|
.where(eq(schema.users.id, acceptorUserId))
|
|
.get();
|
|
const homeUserId = user?.homeUserId || acceptorUserId;
|
|
|
|
const event = {
|
|
eventType: 'dm_call_accept' as const,
|
|
messageId: generateSnowflake(),
|
|
encryptionVersion: 0 as const,
|
|
timestamp: Date.now(),
|
|
federatedId: channel.federatedId,
|
|
call: {
|
|
acceptor: { homeUserId, homeInstance: ourOrigin },
|
|
},
|
|
};
|
|
|
|
await Promise.all(
|
|
Array.from(targets).map(origin =>
|
|
sendCallRelay(origin, [event]).catch(err =>
|
|
console.error(`[federation] Failed to send dm_call_accept to ${origin}:`, err)
|
|
)
|
|
)
|
|
);
|
|
}
|
|
|
|
async function sendFederatedCallEnd(dmChannelId: string, endedByUserId: string): Promise<void> {
|
|
const db = getDb();
|
|
const channel = db.select({ federatedId: schema.dmChannels.federatedId })
|
|
.from(schema.dmChannels)
|
|
.where(eq(schema.dmChannels.id, dmChannelId))
|
|
.get();
|
|
if (!channel?.federatedId) return;
|
|
|
|
const members = db.select({ homeInstance: schema.users.homeInstance })
|
|
.from(schema.dmMembers)
|
|
.innerJoin(schema.users, eq(schema.dmMembers.userId, schema.users.id))
|
|
.where(eq(schema.dmMembers.dmChannelId, dmChannelId))
|
|
.all();
|
|
|
|
const ourOrigin = getOurOrigin();
|
|
const targets = new Set<string>();
|
|
for (const m of members) {
|
|
if (m.homeInstance) {
|
|
const normalized = m.homeInstance.startsWith('http') ? m.homeInstance : `https://${m.homeInstance}`;
|
|
if (normalized !== ourOrigin) targets.add(normalized);
|
|
}
|
|
}
|
|
if (targets.size === 0) return;
|
|
|
|
// Resolve actual homeUserId from DB
|
|
const endUser = db.select({ homeUserId: schema.users.homeUserId })
|
|
.from(schema.users)
|
|
.where(eq(schema.users.id, endedByUserId))
|
|
.get();
|
|
const resolvedHomeUserId = endUser?.homeUserId || endedByUserId;
|
|
|
|
const event = {
|
|
eventType: 'dm_call_end' as const,
|
|
messageId: generateSnowflake(),
|
|
encryptionVersion: 0 as const,
|
|
timestamp: Date.now(),
|
|
federatedId: channel.federatedId,
|
|
call: {
|
|
endedBy: { homeUserId: resolvedHomeUserId, homeInstance: ourOrigin },
|
|
},
|
|
};
|
|
|
|
await Promise.all(
|
|
Array.from(targets).map(origin =>
|
|
sendCallRelay(origin, [event]).catch(err =>
|
|
console.error(`[federation] Failed to send dm_call_end to ${origin}:`, err)
|
|
)
|
|
)
|
|
);
|
|
}
|
|
|
|
// ─── Voice Moderation Handlers ──────────────────────────────────────────────
|
|
|
|
function handleVoiceSpaceMute(event: Record<string, unknown>, userId: string): void {
|
|
const targetUserId = event.userId as string;
|
|
const muted = event.muted === true;
|
|
|
|
if (!targetUserId || typeof targetUserId !== 'string') {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: 'userId is required' });
|
|
return;
|
|
}
|
|
|
|
// Find the target user's current room
|
|
const targetRoom = connectionManager.getUserRoom(targetUserId);
|
|
if (!targetRoom || targetRoom.room.roomType !== 'space') {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: 'Target user is not in a voice channel' });
|
|
return;
|
|
}
|
|
|
|
const meta = targetRoom.room.metadata as SpaceRoomMeta;
|
|
if (!hasPermission(userId, meta.spaceId, PermissionBits.MUTE_MEMBERS, targetRoom.roomId)) {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: 'Missing MUTE_MEMBERS permission' });
|
|
return;
|
|
}
|
|
|
|
// Cannot space-mute yourself
|
|
if (targetUserId === userId) {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: 'Cannot space-mute yourself' });
|
|
return;
|
|
}
|
|
|
|
connectionManager.setSpaceMuted(meta.spaceId, targetUserId, muted);
|
|
|
|
// Persist to DB
|
|
const db = getDb();
|
|
if (muted) {
|
|
db.insert(schema.voiceRestrictions).values({
|
|
spaceId: meta.spaceId,
|
|
userId: targetUserId,
|
|
restrictionType: 'mute',
|
|
moderatorId: userId,
|
|
createdAt: Date.now(),
|
|
}).onConflictDoNothing().run();
|
|
} else {
|
|
db.delete(schema.voiceRestrictions).where(
|
|
and(
|
|
eq(schema.voiceRestrictions.spaceId, meta.spaceId),
|
|
eq(schema.voiceRestrictions.userId, targetUserId),
|
|
eq(schema.voiceRestrictions.restrictionType, 'mute'),
|
|
)
|
|
).run();
|
|
}
|
|
|
|
// Broadcast to all space members
|
|
connectionManager.sendToSpace(meta.spaceId, {
|
|
type: 'voice_space_muted',
|
|
userId: targetUserId,
|
|
channelId: targetRoom.roomId,
|
|
spaceId: meta.spaceId,
|
|
muted,
|
|
});
|
|
}
|
|
|
|
function handleVoiceSpaceDeafen(event: Record<string, unknown>, userId: string): void {
|
|
const targetUserId = event.userId as string;
|
|
const deafened = event.deafened === true;
|
|
|
|
if (!targetUserId || typeof targetUserId !== 'string') {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: 'userId is required' });
|
|
return;
|
|
}
|
|
|
|
const targetRoom = connectionManager.getUserRoom(targetUserId);
|
|
if (!targetRoom || targetRoom.room.roomType !== 'space') {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: 'Target user is not in a voice channel' });
|
|
return;
|
|
}
|
|
|
|
const meta = targetRoom.room.metadata as SpaceRoomMeta;
|
|
if (!hasPermission(userId, meta.spaceId, PermissionBits.DEAFEN_MEMBERS, targetRoom.roomId)) {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: 'Missing DEAFEN_MEMBERS permission' });
|
|
return;
|
|
}
|
|
|
|
if (targetUserId === userId) {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: 'Cannot space-deafen yourself' });
|
|
return;
|
|
}
|
|
|
|
connectionManager.setSpaceDeafened(meta.spaceId, targetUserId, deafened);
|
|
|
|
// Persist to DB
|
|
const db = getDb();
|
|
if (deafened) {
|
|
db.insert(schema.voiceRestrictions).values({
|
|
spaceId: meta.spaceId,
|
|
userId: targetUserId,
|
|
restrictionType: 'deafen',
|
|
moderatorId: userId,
|
|
createdAt: Date.now(),
|
|
}).onConflictDoNothing().run();
|
|
} else {
|
|
db.delete(schema.voiceRestrictions).where(
|
|
and(
|
|
eq(schema.voiceRestrictions.spaceId, meta.spaceId),
|
|
eq(schema.voiceRestrictions.userId, targetUserId),
|
|
eq(schema.voiceRestrictions.restrictionType, 'deafen'),
|
|
)
|
|
).run();
|
|
}
|
|
|
|
connectionManager.sendToSpace(meta.spaceId, {
|
|
type: 'voice_space_deafened',
|
|
userId: targetUserId,
|
|
channelId: targetRoom.roomId,
|
|
spaceId: meta.spaceId,
|
|
deafened,
|
|
});
|
|
}
|
|
|
|
function handleVoiceMove(event: Record<string, unknown>, userId: string): void {
|
|
const targetUserId = event.userId as string;
|
|
const targetChannelId = event.targetChannelId as string;
|
|
|
|
if (!targetUserId || typeof targetUserId !== 'string') {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: 'userId is required' });
|
|
return;
|
|
}
|
|
if (!targetChannelId || typeof targetChannelId !== 'string') {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: 'targetChannelId is required' });
|
|
return;
|
|
}
|
|
|
|
// Find the target user's current room
|
|
const currentRoom = connectionManager.getUserRoom(targetUserId);
|
|
if (!currentRoom || currentRoom.room.roomType !== 'space') {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: 'Target user is not in a voice channel' });
|
|
return;
|
|
}
|
|
|
|
const meta = currentRoom.room.metadata as SpaceRoomMeta;
|
|
if (!hasPermission(userId, meta.spaceId, PermissionBits.MOVE_MEMBERS, currentRoom.roomId)) {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: 'Missing MOVE_MEMBERS permission' });
|
|
return;
|
|
}
|
|
|
|
// Verify target channel exists and is a voice/video channel in the same space
|
|
const db = getDb();
|
|
const targetChannel = db.select().from(schema.channels).where(eq(schema.channels.id, targetChannelId)).get();
|
|
if (!targetChannel || targetChannel.spaceId !== meta.spaceId) {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: 'Target channel not found in this space' });
|
|
return;
|
|
}
|
|
if (targetChannel.type !== 'voice') {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: 'Target channel is not a voice channel' });
|
|
return;
|
|
}
|
|
if (targetChannelId === currentRoom.roomId) {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: 'User is already in that channel' });
|
|
return;
|
|
}
|
|
|
|
const oldChannelId = currentRoom.roomId;
|
|
|
|
// Leave current room
|
|
connectionManager.leaveRoom(oldChannelId, targetUserId);
|
|
|
|
// Broadcast leave from old channel
|
|
connectionManager.sendToSpace(meta.spaceId, {
|
|
type: 'voice_state_update',
|
|
channelId: oldChannelId,
|
|
userId: targetUserId,
|
|
action: 'leave',
|
|
});
|
|
|
|
// Lazy-create target room and join
|
|
connectionManager.createRoom(targetChannelId, 'space', { type: 'space', spaceId: meta.spaceId });
|
|
connectionManager.joinRoom(targetChannelId, targetUserId);
|
|
|
|
// Broadcast join to new channel
|
|
connectionManager.sendToSpace(meta.spaceId, {
|
|
type: 'voice_state_update',
|
|
channelId: targetChannelId,
|
|
userId: targetUserId,
|
|
action: 'join',
|
|
});
|
|
|
|
// Notify the moved user so they reconnect to LiveKit
|
|
connectionManager.sendToUser(targetUserId, {
|
|
type: 'voice_moved',
|
|
userId: targetUserId,
|
|
oldChannelId,
|
|
newChannelId: targetChannelId,
|
|
});
|
|
|
|
// Preserve voice user status during move
|
|
const status = connectionManager.getVoiceUserStatus(targetUserId);
|
|
if (status) {
|
|
connectionManager.sendToRoom(targetChannelId, {
|
|
type: 'voice_status_update',
|
|
userId: targetUserId,
|
|
channelId: targetChannelId,
|
|
isMuted: status.isMuted,
|
|
isDeafened: status.isDeafened,
|
|
isCameraOn: status.isCameraOn,
|
|
isScreenSharing: status.isScreenSharing,
|
|
});
|
|
}
|
|
}
|
|
|
|
function handleVoiceDisconnect(event: Record<string, unknown>, userId: string): void {
|
|
const targetUserId = event.userId as string;
|
|
|
|
if (!targetUserId || typeof targetUserId !== 'string') {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: 'userId is required' });
|
|
return;
|
|
}
|
|
|
|
if (targetUserId === userId) {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: 'Cannot disconnect yourself' });
|
|
return;
|
|
}
|
|
|
|
// Find the target user's current room
|
|
const currentRoom = connectionManager.getUserRoom(targetUserId);
|
|
if (!currentRoom || currentRoom.room.roomType !== 'space') {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: 'Target user is not in a voice channel' });
|
|
return;
|
|
}
|
|
|
|
const meta = currentRoom.room.metadata as SpaceRoomMeta;
|
|
if (!hasPermission(userId, meta.spaceId, PermissionBits.DISCONNECT_MEMBERS, currentRoom.roomId)) {
|
|
connectionManager.sendToUser(userId, { type: 'error', message: 'Missing DISCONNECT_MEMBERS permission' });
|
|
return;
|
|
}
|
|
|
|
const channelId = currentRoom.roomId;
|
|
|
|
// Remove from voice room
|
|
connectionManager.leaveRoom(channelId, targetUserId);
|
|
connectionManager.clearVoiceWs(targetUserId);
|
|
|
|
// Clear ephemeral voice status (mute/camera/etc)
|
|
connectionManager.clearVoiceUserStatus(targetUserId);
|
|
|
|
// Broadcast leave to all space members
|
|
connectionManager.sendToSpace(meta.spaceId, {
|
|
type: 'voice_state_update',
|
|
channelId,
|
|
userId: targetUserId,
|
|
action: 'leave',
|
|
});
|
|
|
|
// Notify the disconnected user so they clean up client-side
|
|
connectionManager.sendToUser(targetUserId, {
|
|
type: 'voice_disconnected',
|
|
userId: targetUserId,
|
|
channelId,
|
|
});
|
|
}
|