chore: Initial commit of Opencord base state
This commit is contained in:
@@ -0,0 +1,409 @@
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import { getDb, schema } from '../db/index.js';
|
||||
import { generateSnowflake } from '../utils/snowflake.js';
|
||||
import { connectionManager } from './handler.js';
|
||||
import { isMember, getChannelServerId, isDmMember } from '../utils/permissions.js';
|
||||
import type { User, MessageWithUser, Attachment, DmMessageWithUser } from '@opencord/shared';
|
||||
|
||||
function sanitizeUser(row: typeof schema.users.$inferSelect): User {
|
||||
return {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
displayName: row.displayName,
|
||||
avatar: row.avatar,
|
||||
status: (row.status ?? 'offline') as User['status'],
|
||||
customStatus: row.customStatus,
|
||||
createdAt: row.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
createdAt: a.createdAt,
|
||||
}));
|
||||
|
||||
return {
|
||||
id: message.id,
|
||||
channelId: message.channelId,
|
||||
userId: message.userId,
|
||||
content: message.content,
|
||||
editedAt: message.editedAt,
|
||||
createdAt: message.createdAt,
|
||||
user: sanitizeUser(user),
|
||||
attachments,
|
||||
};
|
||||
}
|
||||
|
||||
// Typing timeout tracking
|
||||
const typingTimeouts: Map<string, NodeJS.Timeout> = new Map();
|
||||
|
||||
export function handleClientEvent(
|
||||
event: Record<string, unknown>,
|
||||
userId: string,
|
||||
username: string,
|
||||
): void {
|
||||
const type = event.type as string;
|
||||
|
||||
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);
|
||||
break;
|
||||
case 'voice_leave':
|
||||
handleVoiceLeave(userId);
|
||||
break;
|
||||
case 'dm_message_create':
|
||||
handleDmMessageCreate(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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const serverId = getChannelServerId(channelId);
|
||||
if (!serverId) {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'Channel not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isMember(serverId, userId)) {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'Not a member of this server' });
|
||||
return;
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const messageId = generateSnowflake();
|
||||
const now = Date.now();
|
||||
|
||||
db.insert(schema.messages).values({
|
||||
id: messageId,
|
||||
channelId,
|
||||
userId,
|
||||
content: content.trim(),
|
||||
createdAt: now,
|
||||
}).run();
|
||||
|
||||
const messageWithUser = getMessageWithUser(messageId);
|
||||
if (messageWithUser) {
|
||||
// Broadcast to all server members (including sender)
|
||||
connectionManager.sendToServer(serverId, {
|
||||
type: 'message_created',
|
||||
message: messageWithUser,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 serverId = getChannelServerId(message.channelId);
|
||||
if (!serverId) return;
|
||||
|
||||
const updatedMessage = getMessageWithUser(messageId);
|
||||
if (updatedMessage) {
|
||||
connectionManager.sendToServer(serverId, {
|
||||
type: 'message_updated',
|
||||
message: updatedMessage,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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 serverId = getChannelServerId(message.channelId);
|
||||
if (!serverId) return;
|
||||
|
||||
// Allow author or admin to delete
|
||||
const isAuthor = message.userId === userId;
|
||||
const memberRow = db.select()
|
||||
.from(schema.serverMembers)
|
||||
.where(eq(schema.serverMembers.serverId, serverId))
|
||||
.all()
|
||||
.find(m => m.userId === userId);
|
||||
|
||||
const isAdminRole = memberRow?.role === 'admin' || memberRow?.role === 'owner';
|
||||
|
||||
if (!isAuthor && !isAdminRole) {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'You cannot delete this message' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete attachments then message
|
||||
db.delete(schema.attachments).where(eq(schema.attachments.messageId, messageId)).run();
|
||||
db.delete(schema.messages).where(eq(schema.messages.id, messageId)).run();
|
||||
|
||||
connectionManager.sendToServer(serverId, {
|
||||
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 serverId = getChannelServerId(channelId);
|
||||
if (!serverId) return;
|
||||
|
||||
if (!isMember(serverId, userId)) 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 (exclude sender)
|
||||
connectionManager.sendToServer(serverId, {
|
||||
type: 'typing',
|
||||
channelId,
|
||||
userId,
|
||||
username,
|
||||
}, userId);
|
||||
|
||||
// Auto-expire typing after 5 seconds
|
||||
const timeout = setTimeout(() => {
|
||||
typingTimeouts.delete(key);
|
||||
}, 5000);
|
||||
typingTimeouts.set(key, timeout);
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
// Broadcast to all servers user is in
|
||||
const userServers = connectionManager.getUserServers(userId);
|
||||
for (const serverId of userServers) {
|
||||
connectionManager.sendToServer(serverId, {
|
||||
type: 'presence_update',
|
||||
userId,
|
||||
status,
|
||||
}, userId);
|
||||
}
|
||||
|
||||
// Also send to self (other tabs)
|
||||
connectionManager.sendToUser(userId, {
|
||||
type: 'presence_update',
|
||||
userId,
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
function handleVoiceJoin(event: Record<string, unknown>, userId: string): void {
|
||||
const channelId = event.channelId as string;
|
||||
|
||||
if (!channelId || typeof channelId !== 'string') {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'channelId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const serverId = getChannelServerId(channelId);
|
||||
if (!serverId) {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'Channel not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isMember(serverId, userId)) {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'Not a member of this server' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Leave any current voice channel
|
||||
const previousChannel = connectionManager.leaveAllVoice(userId);
|
||||
if (previousChannel) {
|
||||
const prevServerId = getChannelServerId(previousChannel);
|
||||
if (prevServerId) {
|
||||
connectionManager.sendToServer(prevServerId, {
|
||||
type: 'voice_state_update',
|
||||
channelId: previousChannel,
|
||||
userId,
|
||||
action: 'leave',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Join new voice channel
|
||||
connectionManager.joinVoice(channelId, userId);
|
||||
|
||||
// Broadcast to server
|
||||
connectionManager.sendToServer(serverId, {
|
||||
type: 'voice_state_update',
|
||||
channelId,
|
||||
userId,
|
||||
action: 'join',
|
||||
});
|
||||
}
|
||||
|
||||
function handleVoiceLeave(userId: string): void {
|
||||
const channelId = connectionManager.leaveAllVoice(userId);
|
||||
if (channelId) {
|
||||
const serverId = getChannelServerId(channelId);
|
||||
if (serverId) {
|
||||
connectionManager.sendToServer(serverId, {
|
||||
type: 'voice_state_update',
|
||||
channelId,
|
||||
userId,
|
||||
action: 'leave',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleDmMessageCreate(event: Record<string, unknown>, userId: string): void {
|
||||
const dmChannelId = event.dmChannelId as string;
|
||||
const content = event.content as string;
|
||||
|
||||
if (!dmChannelId || typeof dmChannelId !== 'string') {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'dmChannelId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!content || typeof content !== 'string' || content.trim().length === 0) {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'content is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isDmMember(dmChannelId, userId)) {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'Not a member of this DM channel' });
|
||||
return;
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const messageId = generateSnowflake();
|
||||
const now = Date.now();
|
||||
|
||||
db.insert(schema.dmMessages).values({
|
||||
id: messageId,
|
||||
dmChannelId,
|
||||
userId,
|
||||
content: content.trim(),
|
||||
createdAt: now,
|
||||
}).run();
|
||||
|
||||
const user = db.select().from(schema.users).where(eq(schema.users.id, userId)).get();
|
||||
if (!user) return;
|
||||
|
||||
const dmMessage: DmMessageWithUser = {
|
||||
id: messageId,
|
||||
dmChannelId,
|
||||
userId,
|
||||
content: content.trim(),
|
||||
createdAt: now,
|
||||
user: sanitizeUser(user),
|
||||
};
|
||||
|
||||
// Send to all DM members
|
||||
const dmMembers = db.select()
|
||||
.from(schema.dmMembers)
|
||||
.where(eq(schema.dmMembers.dmChannelId, dmChannelId))
|
||||
.all();
|
||||
|
||||
for (const member of dmMembers) {
|
||||
connectionManager.sendToUser(member.userId, {
|
||||
type: 'dm_message_created',
|
||||
message: dmMessage,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { WebSocket } from 'ws';
|
||||
import { verifyJwt } from '../utils/auth.js';
|
||||
import { getDb, schema } from '../db/index.js';
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import { handleClientEvent } from './events.js';
|
||||
import type {
|
||||
User,
|
||||
ServerWithChannelsAndMembers,
|
||||
MemberWithUser,
|
||||
Channel,
|
||||
DmChannel,
|
||||
ServerEvent,
|
||||
} from '@opencord/shared';
|
||||
|
||||
function sanitizeUser(row: typeof schema.users.$inferSelect): User {
|
||||
return {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
displayName: row.displayName,
|
||||
avatar: row.avatar,
|
||||
status: (row.status ?? 'offline') as User['status'],
|
||||
customStatus: row.customStatus,
|
||||
createdAt: row.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
export interface AuthenticatedSocket {
|
||||
ws: WebSocket;
|
||||
userId: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
class ConnectionManager {
|
||||
// userId → Set of WebSocket connections (multiple tabs)
|
||||
private connections: Map<string, Set<WebSocket>> = new Map();
|
||||
// userId → Set of server IDs the user belongs to
|
||||
private userServers: Map<string, Set<string>> = new Map();
|
||||
// channelId → Set of userIds in voice channel
|
||||
private voiceStates: Map<string, Set<string>> = new Map();
|
||||
// ws → userId (reverse lookup)
|
||||
private wsToUser: Map<WebSocket, string> = new Map();
|
||||
|
||||
addConnection(userId: string, ws: WebSocket): void {
|
||||
if (!this.connections.has(userId)) {
|
||||
this.connections.set(userId, new Set());
|
||||
}
|
||||
this.connections.get(userId)!.add(ws);
|
||||
this.wsToUser.set(ws, userId);
|
||||
}
|
||||
|
||||
removeConnection(ws: WebSocket): string | undefined {
|
||||
const userId = this.wsToUser.get(ws);
|
||||
if (!userId) return undefined;
|
||||
|
||||
this.wsToUser.delete(ws);
|
||||
const userConnections = this.connections.get(userId);
|
||||
if (userConnections) {
|
||||
userConnections.delete(ws);
|
||||
if (userConnections.size === 0) {
|
||||
this.connections.delete(userId);
|
||||
}
|
||||
}
|
||||
return userId;
|
||||
}
|
||||
|
||||
getUserConnections(userId: string): Set<WebSocket> {
|
||||
return this.connections.get(userId) ?? new Set();
|
||||
}
|
||||
|
||||
isUserOnline(userId: string): boolean {
|
||||
const conns = this.connections.get(userId);
|
||||
return conns !== undefined && conns.size > 0;
|
||||
}
|
||||
|
||||
setUserServers(userId: string, serverIds: string[]): void {
|
||||
this.userServers.set(userId, new Set(serverIds));
|
||||
}
|
||||
|
||||
addUserServer(userId: string, serverId: string): void {
|
||||
if (!this.userServers.has(userId)) {
|
||||
this.userServers.set(userId, new Set());
|
||||
}
|
||||
this.userServers.get(userId)!.add(serverId);
|
||||
}
|
||||
|
||||
getUserServers(userId: string): Set<string> {
|
||||
return this.userServers.get(userId) ?? new Set();
|
||||
}
|
||||
|
||||
// Voice state management
|
||||
joinVoice(channelId: string, userId: string): void {
|
||||
// Leave any existing voice channel first
|
||||
this.leaveAllVoice(userId);
|
||||
if (!this.voiceStates.has(channelId)) {
|
||||
this.voiceStates.set(channelId, new Set());
|
||||
}
|
||||
this.voiceStates.get(channelId)!.add(userId);
|
||||
}
|
||||
|
||||
leaveVoice(channelId: string, userId: string): void {
|
||||
const users = this.voiceStates.get(channelId);
|
||||
if (users) {
|
||||
users.delete(userId);
|
||||
if (users.size === 0) {
|
||||
this.voiceStates.delete(channelId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
leaveAllVoice(userId: string): string | null {
|
||||
for (const [channelId, users] of this.voiceStates) {
|
||||
if (users.has(userId)) {
|
||||
users.delete(userId);
|
||||
if (users.size === 0) {
|
||||
this.voiceStates.delete(channelId);
|
||||
}
|
||||
return channelId;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
getVoiceUsers(channelId: string): Set<string> {
|
||||
return this.voiceStates.get(channelId) ?? new Set();
|
||||
}
|
||||
|
||||
getUserVoiceChannel(userId: string): string | null {
|
||||
for (const [channelId, users] of this.voiceStates) {
|
||||
if (users.has(userId)) {
|
||||
return channelId;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Send to a specific user (all their connections)
|
||||
sendToUser(userId: string, event: ServerEvent): void {
|
||||
const connections = this.getUserConnections(userId);
|
||||
const message = JSON.stringify(event);
|
||||
for (const ws of connections) {
|
||||
if (ws.readyState === 1) { // WebSocket.OPEN
|
||||
ws.send(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send to all members of a server
|
||||
sendToServer(serverId: string, event: ServerEvent, excludeUserId?: string): void {
|
||||
const message = JSON.stringify(event);
|
||||
for (const [userId, serverIds] of this.userServers) {
|
||||
if (serverIds.has(serverId) && userId !== excludeUserId) {
|
||||
const connections = this.getUserConnections(userId);
|
||||
for (const ws of connections) {
|
||||
if (ws.readyState === 1) {
|
||||
ws.send(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send to all connections of all online users (for global events)
|
||||
sendToAll(event: ServerEvent, excludeUserId?: string): void {
|
||||
const message = JSON.stringify(event);
|
||||
for (const [userId, connections] of this.connections) {
|
||||
if (userId !== excludeUserId) {
|
||||
for (const ws of connections) {
|
||||
if (ws.readyState === 1) {
|
||||
ws.send(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getAllOnlineUserIds(): string[] {
|
||||
return Array.from(this.connections.keys());
|
||||
}
|
||||
}
|
||||
|
||||
export const connectionManager = new ConnectionManager();
|
||||
|
||||
function buildReadyPayload(userId: string): {
|
||||
user: User;
|
||||
servers: ServerWithChannelsAndMembers[];
|
||||
dmChannels: DmChannel[];
|
||||
} {
|
||||
const db = getDb();
|
||||
|
||||
// Get user
|
||||
const userRow = db.select().from(schema.users).where(eq(schema.users.id, userId)).get();
|
||||
if (!userRow) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
const user = sanitizeUser(userRow);
|
||||
|
||||
// Get user's server memberships
|
||||
const memberships = db.select()
|
||||
.from(schema.serverMembers)
|
||||
.where(eq(schema.serverMembers.userId, userId))
|
||||
.all();
|
||||
|
||||
const serverIds = memberships.map(m => m.serverId);
|
||||
|
||||
const servers: ServerWithChannelsAndMembers[] = [];
|
||||
|
||||
if (serverIds.length > 0) {
|
||||
const serverRows = db.select()
|
||||
.from(schema.servers)
|
||||
.where(inArray(schema.servers.id, serverIds))
|
||||
.all();
|
||||
|
||||
for (const serverRow of serverRows) {
|
||||
const channels = db.select()
|
||||
.from(schema.channels)
|
||||
.where(eq(schema.channels.serverId, serverRow.id))
|
||||
.all();
|
||||
|
||||
const memberRows = db.select()
|
||||
.from(schema.serverMembers)
|
||||
.where(eq(schema.serverMembers.serverId, serverRow.id))
|
||||
.all();
|
||||
|
||||
const memberUserIds = memberRows.map(m => m.userId);
|
||||
const users = memberUserIds.length > 0
|
||||
? db.select().from(schema.users).where(inArray(schema.users.id, memberUserIds)).all()
|
||||
: [];
|
||||
const userMap = new Map(users.map(u => [u.id, u]));
|
||||
|
||||
const members: MemberWithUser[] = memberRows
|
||||
.map(m => {
|
||||
const u = userMap.get(m.userId);
|
||||
if (!u) return null;
|
||||
return {
|
||||
serverId: m.serverId,
|
||||
userId: m.userId,
|
||||
role: (m.role ?? 'member') as MemberWithUser['role'],
|
||||
nickname: m.nickname,
|
||||
joinedAt: m.joinedAt,
|
||||
user: sanitizeUser(u),
|
||||
};
|
||||
})
|
||||
.filter((m): m is MemberWithUser => m !== null);
|
||||
|
||||
servers.push({
|
||||
id: serverRow.id,
|
||||
name: serverRow.name,
|
||||
icon: serverRow.icon,
|
||||
ownerId: serverRow.ownerId,
|
||||
inviteCode: serverRow.inviteCode,
|
||||
createdAt: serverRow.createdAt,
|
||||
channels: channels.map(ch => ({
|
||||
id: ch.id,
|
||||
serverId: ch.serverId,
|
||||
name: ch.name,
|
||||
type: ch.type as Channel['type'],
|
||||
topic: ch.topic,
|
||||
position: ch.position ?? 0,
|
||||
createdAt: ch.createdAt,
|
||||
})),
|
||||
members,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Store user's server IDs for broadcasting
|
||||
connectionManager.setUserServers(userId, serverIds);
|
||||
|
||||
// Get DM channels
|
||||
const dmMemberships = db.select()
|
||||
.from(schema.dmMembers)
|
||||
.where(eq(schema.dmMembers.userId, userId))
|
||||
.all();
|
||||
|
||||
const dmChannels: DmChannel[] = [];
|
||||
|
||||
for (const dm of dmMemberships) {
|
||||
const dmChannel = db.select()
|
||||
.from(schema.dmChannels)
|
||||
.where(eq(schema.dmChannels.id, dm.dmChannelId))
|
||||
.get();
|
||||
|
||||
if (!dmChannel) continue;
|
||||
|
||||
const dmMemberRows = db.select()
|
||||
.from(schema.dmMembers)
|
||||
.where(eq(schema.dmMembers.dmChannelId, dm.dmChannelId))
|
||||
.all();
|
||||
|
||||
const dmMemberUserIds = dmMemberRows.map(m => m.userId);
|
||||
const dmUsers = dmMemberUserIds.length > 0
|
||||
? db.select().from(schema.users).where(inArray(schema.users.id, dmMemberUserIds)).all()
|
||||
: [];
|
||||
|
||||
// Get last message
|
||||
const lastMessage = db.select()
|
||||
.from(schema.dmMessages)
|
||||
.where(eq(schema.dmMessages.dmChannelId, dm.dmChannelId))
|
||||
.orderBy(schema.dmMessages.createdAt)
|
||||
.all();
|
||||
|
||||
const last = lastMessage.length > 0 ? lastMessage[lastMessage.length - 1] : null;
|
||||
|
||||
dmChannels.push({
|
||||
id: dmChannel.id,
|
||||
createdAt: dmChannel.createdAt,
|
||||
members: dmUsers.map(sanitizeUser),
|
||||
lastMessage: last ? {
|
||||
id: last.id,
|
||||
dmChannelId: last.dmChannelId,
|
||||
userId: last.userId,
|
||||
content: last.content,
|
||||
createdAt: last.createdAt,
|
||||
} : null,
|
||||
});
|
||||
}
|
||||
|
||||
return { user, servers, dmChannels };
|
||||
}
|
||||
|
||||
export async function registerWebSocket(app: FastifyInstance): Promise<void> {
|
||||
app.get('/ws', { websocket: true }, (socket, request) => {
|
||||
const ws = socket as unknown as WebSocket;
|
||||
let authenticated = false;
|
||||
let userId: string | undefined;
|
||||
let username: string | undefined;
|
||||
|
||||
// Set auth timeout - must authenticate within 10 seconds
|
||||
const authTimeout = setTimeout(() => {
|
||||
if (!authenticated) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Authentication timeout' }));
|
||||
ws.close();
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
ws.on('message', (data: Buffer | string) => {
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
const raw = typeof data === 'string' ? data : data.toString('utf-8');
|
||||
parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
} catch {
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Invalid JSON' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!authenticated) {
|
||||
// First message must be auth
|
||||
if (parsed.type !== 'auth' || typeof parsed.token !== 'string') {
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'First message must be auth' }));
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = verifyJwt(parsed.token);
|
||||
userId = payload.userId;
|
||||
username = payload.username;
|
||||
authenticated = true;
|
||||
clearTimeout(authTimeout);
|
||||
|
||||
// Update user status to online
|
||||
const db = getDb();
|
||||
db.update(schema.users).set({ status: 'online' }).where(eq(schema.users.id, userId)).run();
|
||||
|
||||
// Add connection
|
||||
connectionManager.addConnection(userId, ws);
|
||||
|
||||
// Build and send ready payload
|
||||
const readyData = buildReadyPayload(userId);
|
||||
ws.send(JSON.stringify({
|
||||
type: 'ready',
|
||||
...readyData,
|
||||
}));
|
||||
|
||||
// Broadcast presence update to all servers
|
||||
const userServers = connectionManager.getUserServers(userId);
|
||||
for (const serverId of userServers) {
|
||||
connectionManager.sendToServer(serverId, {
|
||||
type: 'presence_update',
|
||||
userId,
|
||||
status: 'online',
|
||||
}, userId);
|
||||
}
|
||||
} catch {
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Invalid token' }));
|
||||
ws.close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle authenticated events
|
||||
if (userId && username) {
|
||||
handleClientEvent(parsed, userId, username);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
clearTimeout(authTimeout);
|
||||
if (userId) {
|
||||
const removedUserId = connectionManager.removeConnection(ws);
|
||||
|
||||
// If user has no more connections, set offline
|
||||
if (removedUserId && !connectionManager.isUserOnline(removedUserId)) {
|
||||
const db = getDb();
|
||||
db.update(schema.users).set({ status: 'offline' }).where(eq(schema.users.id, removedUserId)).run();
|
||||
|
||||
// Leave voice if in one
|
||||
const leftChannel = connectionManager.leaveAllVoice(removedUserId);
|
||||
if (leftChannel) {
|
||||
// Get channel's server to broadcast
|
||||
const channel = db.select().from(schema.channels).where(eq(schema.channels.id, leftChannel)).get();
|
||||
if (channel) {
|
||||
connectionManager.sendToServer(channel.serverId, {
|
||||
type: 'voice_state_update',
|
||||
channelId: leftChannel,
|
||||
userId: removedUserId,
|
||||
action: 'leave',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast offline to all servers
|
||||
const userServers = connectionManager.getUserServers(removedUserId);
|
||||
for (const serverId of userServers) {
|
||||
connectionManager.sendToServer(serverId, {
|
||||
type: 'presence_update',
|
||||
userId: removedUserId,
|
||||
status: 'offline',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', () => {
|
||||
clearTimeout(authTimeout);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user