mirror of
https://github.com/gromlab-ru/slm-design.git
synced 2026-09-16 09:20:18 +03:00
chore: Новый черновик DRAFT, удалить старые docs-v
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
import { parse as parseCookie } from "cookie";
|
||||
import { Logger } from "@nestjs/common";
|
||||
import {
|
||||
ConnectedSocket,
|
||||
MessageBody,
|
||||
OnGatewayConnection,
|
||||
SubscribeMessage,
|
||||
WebSocketGateway,
|
||||
WebSocketServer,
|
||||
} from "@nestjs/websockets";
|
||||
import type { Server, Socket } from "socket.io";
|
||||
import { ComplexSessionService, SESSION_COOKIE } from "./complex.auth";
|
||||
import { ComplexStore } from "./complex.store";
|
||||
import type { ChatMessageDto, SendMessageDto } from "./dto/chat.dto";
|
||||
|
||||
interface ChatSocketData {
|
||||
userId: string;
|
||||
organizationIds: string[];
|
||||
}
|
||||
|
||||
interface ChatContextPayload {
|
||||
organizationId: string;
|
||||
conversationId: string;
|
||||
}
|
||||
|
||||
interface SocketMessagePayload extends ChatContextPayload, SendMessageDto {}
|
||||
|
||||
@WebSocketGateway({
|
||||
namespace: "/chat",
|
||||
cors: { origin: true, credentials: true },
|
||||
transports: ["websocket", "polling"],
|
||||
})
|
||||
export class ChatGateway implements OnGatewayConnection {
|
||||
@WebSocketServer()
|
||||
server!: Server;
|
||||
|
||||
private readonly logger = new Logger(ChatGateway.name);
|
||||
|
||||
constructor(
|
||||
private readonly sessions: ComplexSessionService,
|
||||
private readonly store: ComplexStore,
|
||||
) {}
|
||||
|
||||
handleConnection(client: Socket): void {
|
||||
const cookies = parseCookie(client.handshake.headers.cookie ?? "");
|
||||
const session = this.sessions.get(cookies[SESSION_COOKIE]);
|
||||
const user = session ? this.store.findUserById(session.userId) : undefined;
|
||||
if (!session || !user) {
|
||||
client.emit("chat:error", {
|
||||
code: "SESSION_EXPIRED",
|
||||
message: "Valid demo_session cookie is required.",
|
||||
});
|
||||
client.disconnect(true);
|
||||
return;
|
||||
}
|
||||
client.data = {
|
||||
userId: user.id,
|
||||
organizationIds: user.organizationIds,
|
||||
} satisfies ChatSocketData;
|
||||
this.logger.debug(`Socket ${client.id} connected as ${user.id}`);
|
||||
}
|
||||
|
||||
@SubscribeMessage("chat:join")
|
||||
join(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() payload: ChatContextPayload,
|
||||
) {
|
||||
try {
|
||||
const data = client.data as ChatSocketData;
|
||||
this.assertOrganization(data, payload.organizationId);
|
||||
this.store.getConversation(
|
||||
payload.organizationId,
|
||||
data.userId,
|
||||
payload.conversationId,
|
||||
);
|
||||
void client.join(this.room(payload.conversationId));
|
||||
return {
|
||||
event: "chat:joined",
|
||||
data: { conversationId: payload.conversationId },
|
||||
};
|
||||
} catch (error) {
|
||||
return { event: "chat:error", data: this.socketError(error) };
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeMessage("chat:leave")
|
||||
leave(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() payload: ChatContextPayload,
|
||||
) {
|
||||
void client.leave(this.room(payload.conversationId));
|
||||
return {
|
||||
event: "chat:left",
|
||||
data: { conversationId: payload.conversationId },
|
||||
};
|
||||
}
|
||||
|
||||
@SubscribeMessage("message:send")
|
||||
send(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() payload: SocketMessagePayload,
|
||||
) {
|
||||
try {
|
||||
const data = client.data as ChatSocketData;
|
||||
this.assertOrganization(data, payload.organizationId);
|
||||
const message = this.store.sendMessage(
|
||||
payload.organizationId,
|
||||
data.userId,
|
||||
payload.conversationId,
|
||||
payload,
|
||||
);
|
||||
this.broadcastMessage(message);
|
||||
return { event: "message:ack", data: message };
|
||||
} catch (error) {
|
||||
return { event: "chat:error", data: this.socketError(error) };
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeMessage("typing:start")
|
||||
typingStart(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() payload: ChatContextPayload,
|
||||
): void {
|
||||
this.broadcastTyping(client, payload, true);
|
||||
}
|
||||
|
||||
@SubscribeMessage("typing:stop")
|
||||
typingStop(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() payload: ChatContextPayload,
|
||||
): void {
|
||||
this.broadcastTyping(client, payload, false);
|
||||
}
|
||||
|
||||
broadcastMessage(message: ChatMessageDto): void {
|
||||
this.server
|
||||
.to(this.room(message.conversationId))
|
||||
.emit("message:created", message);
|
||||
}
|
||||
|
||||
private broadcastTyping(
|
||||
client: Socket,
|
||||
payload: ChatContextPayload,
|
||||
active: boolean,
|
||||
): void {
|
||||
const data = client.data as ChatSocketData;
|
||||
if (!data.organizationIds.includes(payload.organizationId)) return;
|
||||
client
|
||||
.to(this.room(payload.conversationId))
|
||||
.emit(active ? "typing:started" : "typing:stopped", {
|
||||
conversationId: payload.conversationId,
|
||||
userId: data.userId,
|
||||
});
|
||||
}
|
||||
|
||||
private assertOrganization(
|
||||
data: ChatSocketData,
|
||||
organizationId: string,
|
||||
): void {
|
||||
if (!data.organizationIds.includes(organizationId))
|
||||
throw new Error("Organization is unavailable to this user.");
|
||||
}
|
||||
|
||||
private room(conversationId: string): string {
|
||||
return `conversation:${conversationId}`;
|
||||
}
|
||||
|
||||
private socketError(error: unknown): { code: string; message: string } {
|
||||
return {
|
||||
code: "CHAT_OPERATION_FAILED",
|
||||
message:
|
||||
error instanceof Error ? error.message : "Chat operation failed.",
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user