import { Router } from "express"; import { z } from "zod"; import crypto from "crypto"; 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"; import { handleVpnBotDirectMessage, handleVpnBotCallback } from "../vpn/vpn-bot.service"; import { generateBotServiceToken } from "../utils/jwt"; import { sendWebhook } from "../utils/webhook"; import { decrypt } from "../crypto"; 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, botButtons: conversationMessages.botButtons, 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(); 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(), clientMessageId: z.string().max(64).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!; // Идемпотентность: повтор с тем же clientMessageId — вернуть уже созданное // сообщение БЕЗ повторного insert и без side-эффектов (SSE/уведомления // уже отработали при первой записи). Защита от дублей при multi-tab replay // офлайн-очереди (миграция 0087). const clientMessageId = parsed.data.clientMessageId ?? null; if (clientMessageId) { const [existing] = await db .select({ id: conversationMessages.id }) .from(conversationMessages) .where(and( eq(conversationMessages.conversationId, convId), eq(conversationMessages.clientMessageId, clientMessageId) )) .limit(1); if (existing) { const enrichedExisting = await enrichMessage(existing.id); return res.status(200).json({ success: true, message: enrichedExisting, deduplicated: true }); } } let newMsg; try { [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, clientMessageId, }) .returning(); } catch (err) { // Гонка: параллельный запрос с тем же clientMessageId уже вставил // сообщение (unique index conv_messages_client_message_id_unique). const e = err as { code?: string; cause?: { code?: string } }; if (clientMessageId && (e?.code === '23505' || e?.cause?.code === '23505')) { const [existing] = await db .select({ id: conversationMessages.id }) .from(conversationMessages) .where(and( eq(conversationMessages.conversationId, convId), eq(conversationMessages.clientMessageId, clientMessageId) )) .limit(1); if (existing) { const enrichedExisting = await enrichMessage(existing.id); return res.status(200).json({ success: true, message: enrichedExisting, deduplicated: true }); } } throw err; } 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') { 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)); } } else if (bot.type === 'vpn') { 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('[VPN Bot Deny Notify] Error:', err)); return; } await handleVpnBotDirectMessage({ bot: typedBot, conversationId: convId, message: message.trim(), user: req.user!, organizationId: orgId, }); } }); } 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: "Ошибка" }); } } ); // POST /api/messenger/messages/:id/callback — handle bot button click in messenger router.post("/api/messenger/messages/:id/callback", authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const userId = req.user!.id; const messageId = parseInt(req.params.id); if (isNaN(messageId)) { return res.status(400).json({ success: false, error: "Некорректный ID сообщения" }); } const { callbackData } = req.body; if (!callbackData || typeof callbackData !== "string") { return res.status(400).json({ success: false, error: "callbackData обязателен" }); } const [msg] = await db .select({ id: conversationMessages.id, conversationId: conversationMessages.conversationId, botId: conversationMessages.botId, }) .from(conversationMessages) .where(eq(conversationMessages.id, messageId)); if (!msg) { return res.status(404).json({ success: false, error: "Сообщение не найдено" }); } if (!msg.botId) { return res.status(400).json({ success: false, error: "Сообщение не от бота" }); } if (!await isMember(msg.conversationId, userId)) { return res.status(403).json({ success: false, error: "Нет доступа к диалогу" }); } const [bot] = await db .select() .from(bots) .where(and(eq(bots.id, msg.botId), eq(bots.organizationId, req.organizationId!))); if (!bot) { return res.status(404).json({ success: false, error: "Бот не найден" }); } // Inline handler for built-in VPN bot (no external webhook required) if (bot.type === 'vpn') { await handleVpnBotCallback({ bot, conversationId: msg.conversationId, messageId: msg.id, callbackData, user: req.user!, organizationId: req.organizationId!, }); return res.json({ success: true, message: "Callback обработан" }); } if (!bot.webhookUrl || !bot.webhookEnabled) { return res.status(400).json({ success: false, error: "Бот не настроен для обработки callback" }); } const botAccessToken = generateBotServiceToken(bot.id, req.organizationId!); const callbackPayload = { event: "bot_direct_button_callback", timestamp: new Date().toISOString(), organizationId: req.organizationId, data: { conversationId: msg.conversationId, messageId: msg.id, callbackData, bot: { id: bot.id, name: bot.name, accessToken: botAccessToken, }, user: { id: req.user!.id, firstName: req.user!.firstName, middleName: req.user!.middleName, lastName: req.user!.lastName, email: req.user!.email, }, }, }; let signature = ""; if (bot.webhookSecret) { const secret = decrypt(bot.webhookSecret); signature = crypto .createHmac("sha256", secret) .update(JSON.stringify(callbackPayload)) .digest("hex"); } await sendWebhook( bot.webhookUrl, callbackPayload, { "X-Webhook-Signature": signature, "X-Bot-Id": bot.id.toString(), "X-Organization-Id": req.organizationId!.toString(), }, { conversationId: msg.conversationId, organizationId: req.organizationId!, botId: bot.id, eventType: "bot_direct_button_callback", } ); res.json({ success: true, message: "Callback обработан" }); } catch (err) { console.error("Messenger callback error:", err); res.status(500).json({ success: false, error: "Ошибка обработки callback" }); } } ); }