137 lines
4.2 KiB
TypeScript
137 lines
4.2 KiB
TypeScript
import {
|
|
ConnectedSocket,
|
|
MessageBody,
|
|
OnGatewayConnection,
|
|
OnGatewayDisconnect,
|
|
SubscribeMessage,
|
|
WebSocketGateway,
|
|
WebSocketServer,
|
|
} from '@nestjs/websockets';
|
|
import { Logger } from '@nestjs/common';
|
|
import { Server, Socket } from 'socket.io';
|
|
import { introspectGuildToken } from '../common/center-auth';
|
|
|
|
@WebSocketGateway({
|
|
namespace: '/realtime',
|
|
cors: {
|
|
origin: '*',
|
|
},
|
|
})
|
|
export class RealtimeGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
|
@WebSocketServer()
|
|
server!: Server;
|
|
|
|
private readonly logger = new Logger(RealtimeGateway.name);
|
|
private readonly onlineUsers = new Set<string>();
|
|
|
|
private userIdFromClient(client: Socket): string {
|
|
const authUser = client.handshake.auth?.userId;
|
|
const headerUser = client.handshake.headers['x-user-id'];
|
|
const userId = typeof authUser === 'string' ? authUser : Array.isArray(headerUser) ? headerUser[0] : headerUser;
|
|
return userId && typeof userId === 'string' && userId.trim() !== '' ? userId : `anon:${client.id}`;
|
|
}
|
|
|
|
async handleConnection(client: Socket): Promise<void> {
|
|
const authToken = client.handshake.auth?.token;
|
|
const headerAuth = client.handshake.headers['authorization'];
|
|
const bearer =
|
|
typeof authToken === 'string'
|
|
? authToken
|
|
: typeof headerAuth === 'string' && headerAuth.startsWith('Bearer ')
|
|
? headerAuth.slice(7)
|
|
: '';
|
|
|
|
if (!bearer) {
|
|
this.logger.warn(`socket rejected (missing token): ${client.id}`);
|
|
client.disconnect(true);
|
|
return;
|
|
}
|
|
|
|
const result = await introspectGuildToken(bearer);
|
|
if (!result.active || !result.user?.id) {
|
|
this.logger.warn(`socket rejected: ${client.id}`);
|
|
client.disconnect(true);
|
|
return;
|
|
}
|
|
|
|
this.logger.log(`socket connected: ${client.id}`);
|
|
|
|
const userId = result.user.id || this.userIdFromClient(client);
|
|
client.data.userId = userId;
|
|
this.onlineUsers.add(userId);
|
|
this.server.emit('presence.online', {
|
|
userId,
|
|
onlineCount: this.onlineUsers.size,
|
|
occurredAt: new Date().toISOString(),
|
|
});
|
|
}
|
|
|
|
handleDisconnect(client: Socket): void {
|
|
this.logger.log(`socket disconnected: ${client.id}`);
|
|
|
|
const userId = typeof client.data.userId === 'string' ? client.data.userId : `anon:${client.id}`;
|
|
this.onlineUsers.delete(userId);
|
|
this.server.emit('presence.offline', {
|
|
userId,
|
|
onlineCount: this.onlineUsers.size,
|
|
occurredAt: new Date().toISOString(),
|
|
});
|
|
}
|
|
|
|
@SubscribeMessage('join_channel')
|
|
joinChannel(
|
|
@ConnectedSocket() client: Socket,
|
|
@MessageBody() body: { channelId?: string },
|
|
): { ok: boolean } {
|
|
if (!body?.channelId) return { ok: false };
|
|
client.join(`channel:${body.channelId}`);
|
|
return { ok: true };
|
|
}
|
|
|
|
@SubscribeMessage('leave_channel')
|
|
leaveChannel(
|
|
@ConnectedSocket() client: Socket,
|
|
@MessageBody() body: { channelId?: string },
|
|
): { ok: boolean } {
|
|
if (!body?.channelId) return { ok: false };
|
|
client.leave(`channel:${body.channelId}`);
|
|
return { ok: true };
|
|
}
|
|
|
|
@SubscribeMessage('typing.start')
|
|
typingStart(
|
|
@ConnectedSocket() client: Socket,
|
|
@MessageBody() body: { channelId?: string },
|
|
): { ok: boolean } {
|
|
if (!body?.channelId) return { ok: false };
|
|
|
|
const userId = typeof client.data.userId === 'string' ? client.data.userId : `anon:${client.id}`;
|
|
this.server.to(`channel:${body.channelId}`).emit('typing.start', {
|
|
channelId: body.channelId,
|
|
userId,
|
|
occurredAt: new Date().toISOString(),
|
|
});
|
|
return { ok: true };
|
|
}
|
|
|
|
@SubscribeMessage('typing.stop')
|
|
typingStop(
|
|
@ConnectedSocket() client: Socket,
|
|
@MessageBody() body: { channelId?: string },
|
|
): { ok: boolean } {
|
|
if (!body?.channelId) return { ok: false };
|
|
|
|
const userId = typeof client.data.userId === 'string' ? client.data.userId : `anon:${client.id}`;
|
|
this.server.to(`channel:${body.channelId}`).emit('typing.stop', {
|
|
channelId: body.channelId,
|
|
userId,
|
|
occurredAt: new Date().toISOString(),
|
|
});
|
|
return { ok: true };
|
|
}
|
|
|
|
emitChannelEvent(channelId: string, event: string, data: Record<string, unknown>): void {
|
|
this.server.to(`channel:${channelId}`).emit(event, data);
|
|
}
|
|
}
|