Files
iistwin/server/routes/messenger.messages.routes.ts

470 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Router } from "express";
import { z } from "zod";
import { db, openTenantCtx } from "../db";
import { conversations, conversationMembers, conversationMessages, users, bots, type Bot } from "@shared/schema";
import { eq, and, lt, gt, desc, sql, inArray } from "drizzle-orm";
import { authenticateToken, type AuthenticatedRequest } from "../middleware/auth.middleware";
import { tenantIsolation } from "../middleware/tenant.middleware";
import { eventBus } from "./shared";
import { isMember, enrichMessage, getMembersForSSE } from "./messenger.helpers";
import { formatUserName } from "../utils/formatUserName";
import { notificationService } from "../services/notification.service";
import { handleAiBotDirectMessage } from "../services/ai-bot.service";
import { checkBotAccess } from "../services/ai-bot.service";
export function registerMessengerMessageRoutes(router: Router): void {
// GET /api/messenger/conversations/:id/messages
router.get("/api/messenger/conversations/:id/messages",
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const userId = req.user!.id;
const convId = parseInt(req.params.id);
if (isNaN(convId)) return res.status(400).json({ success: false, error: "Некорректный ID" });
if (!await isMember(convId, userId)) {
return res.status(403).json({ success: false, error: "Нет доступа" });
}
const limit = Math.min(parseInt((req.query.limit as string) ?? "50"), 100);
const beforeId = req.query.beforeId ? parseInt(req.query.beforeId as string) : null;
const afterId = req.query.afterId ? parseInt(req.query.afterId as string) : null;
const conditions = [
eq(conversationMessages.conversationId, convId),
eq(conversationMessages.isDeleted, false),
];
if (beforeId !== null && !isNaN(beforeId)) {
conditions.push(lt(conversationMessages.id, beforeId));
}
if (afterId !== null && !isNaN(afterId)) {
conditions.push(gt(conversationMessages.id, afterId));
}
const msgs = await db
.select({
id: conversationMessages.id,
conversationId: conversationMessages.conversationId,
message: conversationMessages.message,
replyToId: conversationMessages.replyToId,
mentionedUserIds: conversationMessages.mentionedUserIds,
attachments: conversationMessages.attachments,
createdAt: conversationMessages.createdAt,
updatedAt: conversationMessages.updatedAt,
isDeleted: conversationMessages.isDeleted,
authorId: conversationMessages.authorId,
botId: conversationMessages.botId,
authorFirstName: users.firstName,
authorMiddleName: users.middleName,
authorLastName: users.lastName,
})
.from(conversationMessages)
.leftJoin(users, eq(conversationMessages.authorId, users.id))
.where(and(...conditions))
.orderBy(desc(conversationMessages.id))
.limit(limit);
// Load bot info for bot messages
const botIds = [...new Set(msgs.filter(m => m.botId).map(m => m.botId!))];
const botInfoMap = new Map<number, { id: number; name: string; avatarUrl: string | null }>();
if (botIds.length > 0) {
const botRows = await db
.select({ id: bots.id, name: bots.name, avatarUrl: bots.avatarUrl })
.from(bots)
.where(inArray(bots.id, botIds));
botRows.forEach(b => botInfoMap.set(b.id, b));
}
const enrichedMsgs = await Promise.all(
msgs.map(async m => {
let replyTo: { id: number; message: string; author: { id: number; firstName: string; middleName: string; lastName: string } } | null = null;
if (m.replyToId) {
const [r] = await db
.select({
id: conversationMessages.id,
message: conversationMessages.message,
authorId: conversationMessages.authorId,
authorFirstName: users.firstName,
authorMiddleName: users.middleName,
authorLastName: users.lastName,
})
.from(conversationMessages)
.leftJoin(users, eq(conversationMessages.authorId, users.id))
.where(and(
eq(conversationMessages.id, m.replyToId),
eq(conversationMessages.conversationId, convId),
));
if (r && r.authorId) {
replyTo = {
id: r.id,
message: r.message,
author: { id: r.authorId, firstName: r.authorFirstName ?? '', middleName: r.authorMiddleName ?? '', lastName: r.authorLastName ?? '' },
};
}
}
const botInfo = m.botId ? botInfoMap.get(m.botId) ?? null : null;
return {
...m,
author: m.authorId
? { id: m.authorId, firstName: m.authorFirstName ?? '', middleName: m.authorMiddleName ?? '', lastName: m.authorLastName ?? '' }
: null,
bot: botInfo,
replyTo,
};
})
);
res.json({ success: true, messages: enrichedMsgs.reverse() });
} catch (err) {
console.error("get messages error:", err);
res.status(500).json({ success: false, error: "Ошибка при загрузке сообщений" });
}
}
);
// POST /api/messenger/conversations/:id/messages — send message
const sendSchema = z.object({
message: z.string().max(10000),
replyToId: z.number().int().positive().optional(),
mentionedUserIds: z.array(z.number().int().positive()).optional(),
attachments: z.array(z.object({
url: z.string(),
name: z.string(),
size: z.number().default(0),
mimeType: z.string().optional(),
})).optional(),
});
router.post("/api/messenger/conversations/:id/messages",
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const userId = req.user!.id;
const convId = parseInt(req.params.id);
if (isNaN(convId)) return res.status(400).json({ success: false, error: "Некорректный ID" });
if (!await isMember(convId, userId)) {
return res.status(403).json({ success: false, error: "Нет доступа" });
}
const parsed = sendSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ success: false, error: "Некорректные данные", details: parsed.error.issues });
}
const { message, replyToId, mentionedUserIds, attachments } = parsed.data;
if (!message.trim() && (!attachments || attachments.length === 0)) {
return res.status(400).json({ success: false, error: "Сообщение или вложения обязательны" });
}
const [conv] = await db
.select({ organizationId: conversations.organizationId, type: conversations.type, name: conversations.name, botId: conversations.botId })
.from(conversations)
.where(eq(conversations.id, convId));
const orgId = conv?.organizationId ?? req.organizationId!;
const [newMsg] = await db
.insert(conversationMessages)
.values({
conversationId: convId,
authorId: userId,
message: message.trim(),
replyToId: replyToId ?? null,
mentionedUserIds: mentionedUserIds?.length ? mentionedUserIds : null,
attachments: attachments?.length ? attachments : null,
})
.returning();
const enriched = await enrichMessage(newMsg.id);
const members = await getMembersForSSE(convId, orgId);
const [author] = await db
.select({ firstName: users.firstName, middleName: users.middleName, lastName: users.lastName })
.from(users)
.where(eq(users.id, userId));
const authorName = author ? formatUserName(author) : 'Пользователь';
const memberMuteRows = await db
.select({ userId: conversationMembers.userId, mutedAt: conversationMembers.mutedAt, externalOrgId: conversationMembers.externalOrgId })
.from(conversationMembers)
.where(eq(conversationMembers.conversationId, convId));
const chatName = conv?.type === 'group' && conv.name
? conv.name
: authorName;
const memberOrgMap = new Map(
memberMuteRows.map(r => [
r.userId,
{ orgId: r.externalOrgId ?? orgId, mutedAt: r.mutedAt ?? null },
])
);
for (const { userId: memberId, orgId: memberOrgId } of members) {
eventBus.publishEvent({
type: "conv_message_created",
data: { conversationId: convId, message: enriched },
organizationId: memberOrgId,
userId: memberId,
});
if (memberId !== userId) {
await db
.update(conversationMembers)
.set({ reactionUnreadCount: sql`COALESCE(reaction_unread_count, 0)` })
.where(and(
eq(conversationMembers.conversationId, convId),
eq(conversationMembers.userId, memberId),
));
}
}
notificationService.notifyMessengerMessage({
conversationId: convId,
authorId: userId,
authorName,
chatName,
chatType: (conv?.type ?? 'direct') as 'direct' | 'group',
messageText: message.trim(),
mentionedUserIds: mentionedUserIds ?? [],
memberOrgMap,
}).catch(err => console.error('[Messenger Notify] Error:', err));
res.status(201).json({ success: true, message: enriched });
// Trigger AI bot response for bot_direct conversations (after response sent to user)
if (conv?.type === 'bot_direct' && conv.botId) {
const botId = conv.botId;
setImmediate(async () => {
try {
const handle = await openTenantCtx(orgId);
try {
await handle.run(async () => {
const [bot] = await db
.select()
.from(bots)
.where(eq(bots.id, botId));
if (!bot || !bot.isActive) return;
if (bot.type !== 'ai_assistant') return;
const typedBot = bot as Bot;
if (!checkBotAccess(typedBot, req.user!)) {
const [denyMsg] = await db
.insert(conversationMessages)
.values({
conversationId: convId,
authorId: null,
botId: bot.id,
message: `⛔ У вас нет доступа к боту ${bot.name}`,
replyToId: null,
mentionedUserIds: null,
attachments: null,
})
.returning();
const enrichedDeny = await enrichMessage(denyMsg.id);
for (const { userId: memberId, orgId: memberOrgId } of members) {
eventBus.publishEvent({ type: "conv_message_created", data: { conversationId: convId, message: enrichedDeny }, organizationId: memberOrgId, userId: memberId });
}
notificationService.notifyMessengerMessage({
conversationId: convId,
authorId: -1,
authorName: bot.name,
chatName: bot.name,
chatType: 'bot_direct',
messageText: `⛔ У вас нет доступа к боту ${bot.name}`,
mentionedUserIds: [],
memberOrgMap,
}).catch(err => console.error('[Bot Deny Notify] Error:', err));
return;
}
await handleAiBotDirectMessage({
bot: typedBot,
conversationId: convId,
message: message.trim(),
user: req.user!,
organizationId: orgId,
});
const latestBotMsg = await db
.select({ id: conversationMessages.id })
.from(conversationMessages)
.where(and(
eq(conversationMessages.conversationId, convId),
eq(conversationMessages.botId, botId),
))
.orderBy(desc(conversationMessages.id))
.limit(1);
if (latestBotMsg[0]) {
const enrichedBot = await enrichMessage(latestBotMsg[0].id);
for (const { userId: memberId, orgId: memberOrgId } of members) {
eventBus.publishEvent({ type: "conv_message_created", data: { conversationId: convId, message: enrichedBot }, organizationId: memberOrgId, userId: memberId });
}
notificationService.notifyMessengerMessage({
conversationId: convId,
authorId: -1,
authorName: bot.name,
chatName: bot.name,
chatType: 'bot_direct',
messageText: (enrichedBot as any).message ?? '',
mentionedUserIds: [],
memberOrgMap,
}).catch(err => console.error('[Bot Reply Notify] Error:', err));
}
});
} finally {
await handle.release().catch(() => {});
}
} catch (err) {
console.error('[Bot Direct] Error handling bot response:', err);
}
});
}
} catch (err) {
console.error("send message error:", err);
res.status(500).json({ success: false, error: "Ошибка при отправке" });
}
}
);
// PATCH /api/messenger/messages/:id — edit message
router.patch("/api/messenger/messages/:id",
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const userId = req.user!.id;
const msgId = parseInt(req.params.id);
if (isNaN(msgId)) return res.status(400).json({ success: false, error: "Некорректный ID" });
const [msg] = await db
.select({ authorId: conversationMessages.authorId, conversationId: conversationMessages.conversationId, isDeleted: conversationMessages.isDeleted })
.from(conversationMessages)
.where(eq(conversationMessages.id, msgId));
if (!msg || msg.isDeleted) return res.status(404).json({ success: false, error: "Сообщение не найдено" });
if (msg.authorId !== userId) return res.status(403).json({ success: false, error: "Нельзя редактировать чужие сообщения" });
const { message } = req.body;
if (typeof message !== "string" || !message.trim()) {
return res.status(400).json({ success: false, error: "Текст не может быть пустым" });
}
await db
.update(conversationMessages)
.set({ message: message.trim(), updatedAt: new Date() })
.where(eq(conversationMessages.id, msgId));
const enriched = await enrichMessage(msgId);
const [conv] = await db
.select({ organizationId: conversations.organizationId })
.from(conversations)
.where(eq(conversations.id, msg.conversationId));
const members = await getMembersForSSE(msg.conversationId, conv?.organizationId ?? req.organizationId!);
for (const { userId: memberId, orgId } of members) {
eventBus.publishEvent({ type: "conv_message_updated", data: { conversationId: msg.conversationId, message: enriched }, organizationId: orgId, userId: memberId });
}
res.json({ success: true, message: enriched });
} catch (err) {
console.error("edit message error:", err);
res.status(500).json({ success: false, error: "Ошибка" });
}
}
);
// DELETE /api/messenger/messages/:id — soft-delete message
router.delete("/api/messenger/messages/:id",
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const userId = req.user!.id;
const msgId = parseInt(req.params.id);
if (isNaN(msgId)) return res.status(400).json({ success: false, error: "Некорректный ID" });
const [msg] = await db
.select({ authorId: conversationMessages.authorId, conversationId: conversationMessages.conversationId, isDeleted: conversationMessages.isDeleted })
.from(conversationMessages)
.where(eq(conversationMessages.id, msgId));
if (!msg || msg.isDeleted) return res.status(404).json({ success: false, error: "Сообщение не найдено" });
const isAdmin = req.user?.appRole === "admin";
if (msg.authorId !== userId && !isAdmin) {
return res.status(403).json({ success: false, error: "Нельзя удалить чужое сообщение" });
}
await db
.update(conversationMessages)
.set({ isDeleted: true, message: "", updatedAt: new Date() })
.where(eq(conversationMessages.id, msgId));
const [conv] = await db
.select({ organizationId: conversations.organizationId })
.from(conversations)
.where(eq(conversations.id, msg.conversationId));
const members = await getMembersForSSE(msg.conversationId, conv?.organizationId ?? req.organizationId!);
for (const { userId: memberId, orgId } of members) {
eventBus.publishEvent({ type: "conv_message_deleted", data: { id: msgId, conversationId: msg.conversationId }, organizationId: orgId, userId: memberId });
}
res.json({ success: true });
} catch (err) {
console.error("delete message error:", err);
res.status(500).json({ success: false, error: "Ошибка" });
}
}
);
// POST /api/messenger/conversations/:id/read — mark conversation as read
router.post("/api/messenger/conversations/:id/read",
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const userId = req.user!.id;
const convId = parseInt(req.params.id);
if (isNaN(convId)) return res.status(400).json({ success: false, error: "Некорректный ID" });
if (!await isMember(convId, userId)) {
return res.status(403).json({ success: false, error: "Нет доступа" });
}
await db
.update(conversationMembers)
.set({ lastReadAt: new Date(), reactionUnreadCount: 0 })
.where(and(
eq(conversationMembers.conversationId, convId),
eq(conversationMembers.userId, userId),
));
const [conv] = await db
.select({ organizationId: conversations.organizationId })
.from(conversations)
.where(eq(conversations.id, convId));
const members = await getMembersForSSE(convId, conv?.organizationId ?? req.organizationId!);
for (const { userId: memberId, orgId } of members) {
eventBus.publishEvent({
type: "conv_read",
data: { conversationId: convId, userId },
organizationId: orgId,
userId: memberId,
});
}
res.json({ success: true });
} catch (err) {
console.error("mark read error:", err);
res.status(500).json({ success: false, error: "Ошибка" });
}
}
);
}