Атрибуция от бота в MCP/REST + REST API по ключу (этап 2)

- MCP: getActor (владелец ключа + бот), аудит changedByName=бот/label ключа, botId, metadata.source='mcp'; аудит для update_task/update_task_status
- sendTaskMessage: botId (messageType 'bot'), починен путь isBotToken без req.user
- authenticateTokenOrApiKey: JWT или X-Api-Key с белым списком endpoint'ов (/api/upload, messages, field-values, create task), проверки скоупов, аудит source='api'
- Лимиты файлов через env: UPLOAD_IMAGE_MAX_MB=25, UPLOAD_DOC_MAX_MB=100, UPLOAD_MAX_MB=100
- MCP upload-инструменты: параметр fileUrl для привязки уже загруженного файла; новый инструмент get_api_guide
This commit is contained in:
2026-07-22 15:18:04 +03:00
parent 68153caef2
commit 8cfd49fd9f
13 changed files with 720 additions and 246 deletions

View File

@@ -23,7 +23,8 @@ type MessageAttachment = { url: string; name: string; size: number; mimeType?: s
export interface SendTaskMessageParams {
task: Task; // задача (уже загружена и проверена вызывающим кодом)
user: User; // автор сообщения
user?: User; // автор сообщения; необязателен, если передан botId
botId?: number | null; // сообщение от бота: authorId=null, botId, messageType='bot'
organizationId: number;
message: string;
messageType?: string; // 'comment' (default), 'bot', 'system', ...
@@ -41,8 +42,14 @@ export interface SendTaskMessageParams {
// постановка embedding в очередь, запись взаимодействия с задачей.
// Логика вынесена из POST /api/tasks/:id/messages (routes/chat.messages.routes.ts)
// и переиспользуется MCP-сервером — поведение не менять.
// При botId (сообщение от бота) авторство и пользовательские side-эффекты пропускаются,
// как в POST /api/bot/message (bot-api.routes.ts).
export async function sendTaskMessage(params: SendTaskMessageParams): Promise<TaskMessage> {
const { task, user, organizationId } = params;
const botId = params.botId ?? null;
if (!user && !botId) {
throw new SendTaskMessageError('Не указан автор сообщения (user или botId)', 500);
}
const taskId = task.id;
let replyToMessage = null;
@@ -59,7 +66,7 @@ export async function sendTaskMessage(params: SendTaskMessageParams): Promise<Ta
const orgUsers = await storage.getUsersByOrganization(organizationId);
const orgUserIds = orgUsers.map(u => u.id);
mentionedUserIds = params.mentionedUserIds.filter(id =>
orgUserIds.includes(id) && id !== user.id
orgUserIds.includes(id) && id !== user?.id
);
}
@@ -67,15 +74,16 @@ export async function sendTaskMessage(params: SendTaskMessageParams): Promise<Ta
// 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 (isBotToken = true)
const messageType = params.messageType || (botId ? 'bot' : 'comment');
const isBotOrSystemMessage =
params.messageType === 'bot' ||
params.messageType === 'system' ||
messageType === 'bot' ||
messageType === 'system' ||
params.bodyAuthorId === null ||
params.isBotToken === true;
let mentionedBotIds: number[] = [];
let mentionedBotsMap = new Map<number, Bot>();
if (!isBotOrSystemMessage && params.mentionedBotIds && params.mentionedBotIds.length > 0) {
if (!isBotOrSystemMessage && user && params.mentionedBotIds && params.mentionedBotIds.length > 0) {
const orgBots = await storage.getBotsByOrganization(organizationId);
const activeBots = orgBots.filter(b => b.isActive);
activeBots.forEach(bot => mentionedBotsMap.set(bot.id, bot));
@@ -86,10 +94,11 @@ export async function sendTaskMessage(params: SendTaskMessageParams): Promise<Ta
const messageData = {
taskId,
formId: task.formId,
authorId: user.id,
authorId: botId ? null : (user?.id ?? null),
botId,
replyToMessageId: params.replyToMessageId || null,
message: params.message || '',
messageType: params.messageType || 'comment',
messageType,
mentionedUserIds: mentionedUserIds.length > 0 ? mentionedUserIds : null,
attachments: params.attachments?.length ? params.attachments : null,
};
@@ -127,7 +136,7 @@ export async function sendTaskMessage(params: SendTaskMessageParams): Promise<Ta
}
}
if (mentionedBotIds.length > 0) {
if (mentionedBotIds.length > 0 && user) {
const taskFieldValues = await storage.getTaskFieldValues(taskId, organizationId);
const form = await storage.getForm(task.formId, organizationId);
const taskWithFields = { ...task, fieldValues: taskFieldValues };
@@ -226,40 +235,44 @@ export async function sendTaskMessage(params: SendTaskMessageParams): Promise<Ta
taskId: taskId
});
const notificationEvent: NotificationEvent = {
type: replyToMessage ? EVENT_TYPES.TASK_COMMENT_REPLIED : EVENT_TYPES.TASK_COMMENT_CREATED,
organizationId,
triggeredBy: 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: `${user.firstName || ''} ${user.middleName || ''} ${user.lastName || ''}`.trim(),
originalAuthorId: replyToMessage?.authorId,
},
mentionedUserIds: mentionedUserIds,
timestamp: new Date(),
};
// Уведомления и исходящие вебхуки — только для сообщений от пользователя
// (для bot-сообщений — как в POST /api/bot/message: без notificationService).
if (user) {
const notificationEvent: NotificationEvent = {
type: replyToMessage ? EVENT_TYPES.TASK_COMMENT_REPLIED : EVENT_TYPES.TASK_COMMENT_CREATED,
organizationId,
triggeredBy: 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: `${user.firstName || ''} ${user.middleName || ''} ${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,
userId: userId
notificationService.processEvent(notificationEvent).then(notifiedUserIds => {
notifiedUserIds.forEach(userId => {
eventBus.publishEvent({
type: 'notification',
data: { type: notificationEvent.type, taskId, messageId: createdMessage.id },
organizationId,
userId: userId
});
});
});
}).catch(err => console.error('Notification processing error:', err));
}).catch(err => console.error('Notification processing error:', err));
if (messageData.messageType === 'comment') {
webhookService.dispatchComment(
organizationId, taskId, task.formId, createdMessage, user.id
).catch(err => console.error('Webhook dispatch error:', err));
if (messageData.messageType === 'comment') {
webhookService.dispatchComment(
organizationId, taskId, task.formId, createdMessage, user.id
).catch(err => console.error('Webhook dispatch error:', err));
}
}
if (messageData.messageType === 'comment') {
@@ -267,7 +280,7 @@ export async function sendTaskMessage(params: SendTaskMessageParams): Promise<Ta
.catch(err => console.error('[RAG] enqueue message embedding error:', err));
}
if (user.id) {
if (user?.id) {
storage.recordTaskInteractionAuto(taskId, user.id, organizationId)
.catch(err => console.error('recordTaskInteraction error:', err));
}