MCP: сообщения задач, исполнители, документы, файлы, аудит — этап 2

- list_task_messages, get_task_assignees, list_document_templates, get_task_file, get_task_audit_log (read)
- send_task_message, generate_document (write); set_task_assignees (full)
- Логика POST /api/tasks/:id/messages вынесена в server/services/task-message.service.ts (переиспользуется route и MCP)
- get_task_file: нативный S3 presigned URL или локальный путь с пояснением
This commit is contained in:
2026-07-21 17:19:22 +03:00
parent 002ce152be
commit 05e09fa08b
3 changed files with 778 additions and 242 deletions

View File

@@ -4,20 +4,9 @@ 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 { createTaskMessageSchema } from "@shared/schema";
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";
import { sendTaskMessage, SendTaskMessageError } from "../services/task-message.service";
export function registerChatMessageRoutes(router: Router): void {
// Get task messages
@@ -86,238 +75,34 @@ export function registerChatMessageRoutes(router: Router): void {
}
}
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<number, Bot>();
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!)) {
let denialMessage;
try {
denialMessage = 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);
}
if (denialMessage) {
eventBus.publishEvent({
type: 'message_created',
data: {
taskId,
message: {
...denialMessage,
bot: bot ? { id: bot.id, name: bot.name, avatarUrl: bot.avatarUrl ?? null } : null,
},
},
organizationId: req.organizationId,
taskId,
});
}
continue;
}
handleAiBotMention({
bot,
task,
message: createdMessage.message,
attachments: createdMessage.attachments || undefined,
user: req.user!,
organizationId: req.organizationId!,
}).catch(err => console.error(`[AI-Bot] Error for bot ${bot.name}:`, err));
// postBotReply внутри handleAiBotMention сам публикует message_created с полным сообщением
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
});
// Основная логика (сообщение + side-эффекты) вынесена в сервис —
// переиспользуется также MCP-сервером (инструмент send_task_message).
try {
const createdMessage = await sendTaskMessage({
task,
user: req.user!,
organizationId: req.organizationId!,
message: req.body.message || '',
messageType: req.body.messageType,
replyToMessageId: req.body.replyToMessageId,
mentionedUserIds: req.body.mentionedUserIds,
mentionedBotIds: req.body.mentionedBotIds,
attachments: req.body.attachments,
bodyAuthorId: req.body.authorId,
isBotToken: req.isBotToken === true,
});
}).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));
res.status(201).json({
success: true,
message: 'Сообщение добавлено',
taskMessage: createdMessage
});
} catch (err) {
if (err instanceof SendTaskMessageError) {
return res.status(err.status).json({ success: false, error: err.message });
}
throw 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: 'Ошибка при создании сообщения' });