import { Router } from "express"; import { z } from 'zod'; import { storage } from "../storage"; import { authenticateToken, tryBotServiceToken, type AuthenticatedRequest } from "../middleware/auth.middleware"; import { tenantIsolation } from "../middleware/tenant.middleware"; import { validateRequest } from "../middleware/validation.middleware"; import { createTaskMessageSchema, type NotificationEvent, type Bot, } from "@shared/schema"; import { notificationService, EVENT_TYPES } from "../services/notification.service"; import { webhookService } from "../services/webhook.service"; import { generateBotServiceToken } from "../utils/jwt"; import { sendWebhook } from "../utils/webhook"; import { eventBus } from "./shared"; import { handleAiBotMention, checkBotAccess } from "../services/ai-bot.service"; import { db } from "../db"; import { fileUploads } from "@shared/schema"; import { eq } from "drizzle-orm"; export function registerChatMessageRoutes(router: Router): void { // Get task messages router.get('/api/tasks/:id/messages', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const taskId = parseInt(req.params.id); if (isNaN(taskId)) { return res.status(400).json({ success: false, error: 'Некорректный ID задачи' }); } const taskRolesForRead = await storage.getTaskRoles(taskId, req.organizationId!); if (taskRolesForRead.length > 0) { const hasAccess = await storage.canUserAccessTask( taskId, req.user!.id, req.organizationId!, req.user!.appRole ); if (!hasAccess) { return res.status(403).json({ success: false, error: 'Нет доступа к этой задаче' }); } } const messages = await storage.getTaskMessages(taskId, req.organizationId!); res.json({ success: true, messages }); } catch (error) { console.error('Get task messages error:', error); res.status(500).json({ success: false, error: 'Ошибка при загрузке сообщений' }); } } ); // Create task message router.post('/api/tasks/:id/messages', tryBotServiceToken, authenticateToken, tenantIsolation, validateRequest(createTaskMessageSchema.omit({ taskId: true })), async (req: AuthenticatedRequest, res) => { try { const taskId = parseInt(req.params.id); if (isNaN(taskId)) { return res.status(400).json({ success: false, error: 'Некорректный ID задачи' }); } const { message: msgBody, attachments: msgAttachments } = req.body; if (!msgBody?.trim() && (!msgAttachments || msgAttachments.length === 0)) { return res.status(400).json({ success: false, error: 'Сообщение или вложения обязательны' }); } const task = await storage.getTask(taskId, req.organizationId!); if (!task) { return res.status(404).json({ success: false, error: 'Задача не найдена' }); } { const taskRolesForGuard = await storage.getTaskRoles(taskId, req.organizationId!); if (taskRolesForGuard.length > 0) { const hasAccess = await storage.canUserAccessTask( taskId, req.user!.id, req.organizationId!, req.user!.appRole ); if (!hasAccess) { return res.status(403).json({ success: false, error: 'Нет доступа к этой задаче' }); } } } let replyToMessage = null; if (req.body.replyToMessageId) { const existingMessages = await storage.getTaskMessages(taskId, req.organizationId!); replyToMessage = existingMessages.find(m => m.id === req.body.replyToMessageId); if (!replyToMessage) { return res.status(400).json({ success: false, error: 'Сообщение для ответа не найдено' }); } } let mentionedUserIds: number[] = []; if (req.body.mentionedUserIds && req.body.mentionedUserIds.length > 0) { const orgUsers = await storage.getUsersByOrganization(req.organizationId!); const orgUserIds = orgUsers.map(user => user.id); mentionedUserIds = req.body.mentionedUserIds.filter((id: number) => orgUserIds.includes(id) && id !== req.user!.id ); } // Prevent bot response loops: skip bot processing when: // 1. messageType is 'bot' or 'system' (messages authored by the bot service) // 2. authorId is null in the payload (another indicator of a bot/system message) // 3. Request was authenticated via a bot-service JWT (req.isBotToken = true) const isBotOrSystemMessage = req.body.messageType === 'bot' || req.body.messageType === 'system' || req.body.authorId === null || req.isBotToken === true; let mentionedBotIds: number[] = []; let mentionedBotsMap = new Map(); if (!isBotOrSystemMessage && req.body.mentionedBotIds && req.body.mentionedBotIds.length > 0) { const orgBots = await storage.getBotsByOrganization(req.organizationId!); const activeBots = orgBots.filter(b => b.isActive); activeBots.forEach(bot => mentionedBotsMap.set(bot.id, bot)); const orgBotIds = activeBots.map(bot => bot.id); mentionedBotIds = req.body.mentionedBotIds.filter((id: number) => orgBotIds.includes(id)); } const messageData = { taskId, formId: task.formId, authorId: req.user!.id, replyToMessageId: req.body.replyToMessageId || null, message: req.body.message || '', messageType: req.body.messageType || 'comment', mentionedUserIds: mentionedUserIds.length > 0 ? mentionedUserIds : null, attachments: req.body.attachments?.length ? req.body.attachments : null, }; const createdMessage = await storage.createTaskMessage(messageData, req.organizationId!); // Link file uploads to this task if (createdMessage.attachments && createdMessage.attachments.length > 0) { try { for (const att of createdMessage.attachments) { const fileKey = att.url.startsWith('/api/files/') ? att.url.replace('/api/files/', '') : (att.url.startsWith('/uploads/') ? att.url.replace('/uploads/', '') : att.url); await db.update(fileUploads).set({ taskId }).where(eq(fileUploads.fileKey, fileKey)); } } catch (err) { console.warn('[Chat] Failed to link file uploads to task:', err); } } if (mentionedUserIds.length > 0) { try { await Promise.all( mentionedUserIds.map(uid => storage.upsertTaskUserAccess(taskId, uid, req.organizationId!, 'mention') ) ); // Notify subscribers that the task has changed (new mention access) eventBus.publishEvent({ type: 'task_updated', data: { taskId: taskId, formId: task.formId, task }, organizationId: req.organizationId!, taskId: taskId }); } catch (err) { console.error('upsertTaskUserAccess error for mention:', err); } } if (mentionedBotIds.length > 0) { const taskFieldValues = await storage.getTaskFieldValues(taskId, req.organizationId!); const form = await storage.getForm(task.formId, req.organizationId!); const taskWithFields = { ...task, fieldValues: taskFieldValues }; for (const botId of mentionedBotIds) { const bot = mentionedBotsMap.get(botId); if (!bot) continue; if (bot.type === 'ai_assistant') { if (!checkBotAccess(bot, req.user!)) { try { await storage.createTaskMessage({ taskId, formId: task.formId, authorId: null, botId: bot.id, replyToMessageId: null, message: `⛔ У вас нет доступа к боту ${bot.name}`, messageType: 'system', mentionedUserIds: null, attachments: null, }, req.organizationId!); } catch (err) { console.error(`[AI-Bot] Failed to post denial message for bot ${bot.id}:`, err); } eventBus.publishEvent({ type: 'message_created', data: { taskId }, organizationId: req.organizationId, taskId, }); continue; } handleAiBotMention({ bot, task, message: createdMessage.message, attachments: createdMessage.attachments || undefined, user: req.user!, organizationId: req.organizationId!, }).then(() => { eventBus.publishEvent({ type: 'message_created', data: { taskId }, organizationId: req.organizationId, taskId, }); }).catch(err => console.error(`[AI-Bot] Error for bot ${bot.name}:`, err)); continue; } if (bot.webhookUrl) { try { const botAccessToken = generateBotServiceToken(bot.id, req.organizationId!); const webhookPayload = { event: 'bot_mentioned', timestamp: new Date().toISOString(), organizationId: req.organizationId, data: { task: taskWithFields, form: form ? { id: form.id, name: form.name } : null, message: { id: createdMessage.id, text: createdMessage.message, authorId: req.user!.id, authorName: `${req.user!.firstName} ${req.user!.middleName || ''} ${req.user!.lastName}`.trim() }, bot: { id: bot.id, name: bot.name, accessToken: botAccessToken } } }; sendWebhook( bot.webhookUrl, webhookPayload, { 'X-Bot-Id': bot.id.toString(), 'X-Organization-Id': req.organizationId!.toString() }, { taskId, organizationId: req.organizationId!, botId: bot.id, eventType: 'bot_mentioned' } ).catch(err => console.error(`Webhook to bot ${bot.name} failed:`, err)); } catch (err) { console.error(`Error sending webhook to bot ${bot.id}:`, err); } } } } eventBus.publishEvent({ type: 'message_created', data: { taskId: taskId, message: createdMessage }, organizationId: req.organizationId, taskId: taskId }); const notificationEvent: NotificationEvent = { type: replyToMessage ? EVENT_TYPES.TASK_COMMENT_REPLIED : EVENT_TYPES.TASK_COMMENT_CREATED, organizationId: req.organizationId!, triggeredBy: req.user!.id, taskId: taskId, formId: task.formId, messageId: createdMessage.id, payload: { message: createdMessage.message.length > 100 ? createdMessage.message.substring(0, 100) + '...' : createdMessage.message, taskTitle: task.title, authorName: `${req.user!.firstName} ${req.user!.middleName || ''} ${req.user!.lastName}`.trim(), originalAuthorId: replyToMessage?.authorId, }, mentionedUserIds: mentionedUserIds, timestamp: new Date(), }; notificationService.processEvent(notificationEvent).then(notifiedUserIds => { notifiedUserIds.forEach(userId => { eventBus.publishEvent({ type: 'notification', data: { type: notificationEvent.type, taskId, messageId: createdMessage.id }, organizationId: req.organizationId!, userId: userId }); }); }).catch(err => console.error('Notification processing error:', err)); if (messageData.messageType === 'comment') { webhookService.dispatchComment( req.organizationId!, taskId, task.formId, createdMessage, req.user!.id ).catch(err => console.error('Webhook dispatch error:', err)); } if (messageData.messageType === 'comment') { storage.enqueueEmbedding(req.organizationId!, 'task_message', createdMessage.id, 'upsert') .catch(err => console.error('[RAG] enqueue message embedding error:', err)); } if (req.user?.id) { storage.recordTaskInteractionAuto(taskId, req.user.id, req.organizationId!) .catch(err => console.error('recordTaskInteraction error:', err)); } res.status(201).json({ success: true, message: 'Сообщение добавлено', taskMessage: createdMessage }); } catch (error) { console.error('Create task message error:', error); res.status(500).json({ success: false, error: 'Ошибка при создании сообщения' }); } } ); // Update task message router.put('/api/messages/:id', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const messageId = parseInt(req.params.id); if (isNaN(messageId)) { return res.status(400).json({ success: false, error: 'Некорректный ID сообщения' }); } const { message } = req.body; if (!message || typeof message !== 'string' || message.trim().length === 0) { return res.status(400).json({ success: false, error: 'Текст сообщения обязателен' }); } const existingMessage = await storage.getTaskMessage(messageId, req.organizationId!); if (!existingMessage) { return res.status(404).json({ success: false, error: 'Сообщение не найдено' }); } if (existingMessage.authorId !== req.user!.id && req.user!.appRole !== 'admin') { return res.status(403).json({ success: false, error: 'Нет прав для редактирования этого сообщения' }); } const updatedMessage = await storage.updateTaskMessage( messageId, req.organizationId!, { message: message.trim() } ); res.json({ success: true, message: 'Сообщение обновлено', taskMessage: updatedMessage }); } catch (error) { console.error('Update task message error:', error); res.status(500).json({ success: false, error: 'Ошибка при обновлении сообщения' }); } } ); // Delete task message router.delete('/api/messages/:id', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const messageId = parseInt(req.params.id); if (isNaN(messageId)) { return res.status(400).json({ success: false, error: 'Некорректный ID сообщения' }); } const existingMessage = await storage.getTaskMessage(messageId, req.organizationId!); if (!existingMessage) { return res.status(404).json({ success: false, error: 'Сообщение не найдено' }); } if (existingMessage.authorId !== req.user!.id && req.user!.appRole !== 'admin') { return res.status(403).json({ success: false, error: 'Нет прав для удаления этого сообщения' }); } await storage.deleteTaskMessage(messageId, req.organizationId!); res.json({ success: true, message: 'Сообщение удалено' }); } catch (error) { console.error('Delete task message error:', error); res.status(500).json({ success: false, error: 'Ошибка при удалении сообщения' }); } } ); // Mark message as read router.post('/api/messages/:id/read', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const messageId = parseInt(req.params.id); if (isNaN(messageId)) { return res.status(400).json({ success: false, error: 'Некорректный ID сообщения' }); } const targetMessage = await storage.getTaskMessage(messageId, req.organizationId!); if (!targetMessage) { return res.status(404).json({ success: false, error: 'Сообщение не найдено' }); } const messageRead = await storage.markMessageAsRead( messageId, req.user!.id, req.organizationId! ); if (targetMessage && targetMessage.authorId !== req.user!.id) { eventBus.publishEvent({ type: 'message_read', data: { messageId: messageId, taskId: targetMessage.taskId, readerId: req.user!.id, readerName: `${req.user!.firstName} ${req.user!.middleName || ''} ${req.user!.lastName}`.trim(), authorId: targetMessage.authorId }, organizationId: req.organizationId!, userId: targetMessage.authorId ?? undefined }); } res.json({ success: true, message: 'Сообщение отмечено как прочитанное', messageRead }); } catch (error) { console.error('Mark message as read error:', error); res.status(500).json({ success: false, error: 'Ошибка при отметке сообщения как прочитанного' }); } } ); // Get message read receipts router.get('/api/messages/:id/reads', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const messageId = parseInt(req.params.id); if (isNaN(messageId)) { return res.status(400).json({ success: false, error: 'Некорректный ID сообщения' }); } const message = await storage.getTaskMessage(messageId, req.organizationId!); if (!message) { return res.status(404).json({ success: false, error: 'Сообщение не найдено' }); } const reads = await storage.getMessageReads(messageId, req.organizationId!); res.json({ success: true, reads }); } catch (error) { console.error('Get message reads error:', error); res.status(500).json({ success: false, error: 'Ошибка при получении статуса прочтения' }); } } ); }