import express from 'express'; import crypto from 'crypto'; import type { Response, NextFunction } from "express"; import { storage } from "../storage"; import { authenticateToken, type AuthenticatedRequest } from "../middleware/auth.middleware"; import { tenantIsolation } from "../middleware/tenant.middleware"; import { validateRequest } from "../middleware/validation.middleware"; import { botMessageSchema, conversations, conversationMessages, conversationMembers, users, bots } from "@shared/schema"; import { verifyBotLoginToken, verifyBotServiceToken } from "../utils/jwt"; import { sendWebhook } from "../utils/webhook"; import { eventBus } from "./shared"; import { pushTaskUpdated } from "../utils/pushTaskUpdated"; import { decrypt } from "../crypto"; import { withSuperAdmin, openTenantCtx, _tenantCtx, db } from "../db"; import { eq, and, desc, sql, lt, inArray } from "drizzle-orm"; import { enrichMessage, getMembersForSSE } from "./messenger.helpers"; import { notificationService } from "../services/notification.service"; export function registerBotApiRoutes(app: import("express").Express): void { // ===================================================== // BOT ACTIONS API - для ботов отправлять сообщения // ===================================================== // Middleware для аутентификации ботов // Поддерживает два типа токенов: // 1. Токен логина бота (type: 'bot_login') - получается через /api/bot/auth/login // 2. Сервисный токен (type: 'bot_service') - передаётся в webhook при @mention const authenticateBot = async (req: AuthenticatedRequest & { bot?: Record }, res: Response, next: NextFunction) => { try { const authHeader = req.headers.authorization; if (!authHeader || !authHeader.startsWith('Bearer ')) { return res.status(401).json({ success: false, error: 'Токен не предоставлен' }); } const token = authHeader.substring(7); // Принимаются два типа токенов: // 1. bot_login (POST /api/bot/auth/login) — через verifyBotLoginToken // 2. bot_service (webhook @mention) — через verifyBotServiceToken let botId: number; let organizationId: number; try { const decoded = verifyBotLoginToken(token); botId = decoded.botId; organizationId = decoded.organizationId; } catch { // Попробуем как сервисный токен try { const serviceDecoded = verifyBotServiceToken(token); botId = serviceDecoded.botId; organizationId = serviceDecoded.organizationId; } catch { return res.status(401).json({ success: false, error: 'Недействительный токен бота' }); } } const bot = await withSuperAdmin(() => storage.getBot(botId, organizationId)); if (!bot || !bot.isActive) { return res.status(401).json({ success: false, error: 'Бот не найден или деактивирован' }); } req.bot = bot; req.organizationId! = organizationId; if (_tenantCtx.getStore()) { return next(); } openTenantCtx(organizationId) .then((handle) => { handle.run(() => { const guard = setTimeout(() => { console.warn(`[POOL] Force-releasing bot tenant connection after 30s timeout (${req.method} ${req.path})`); handle.release(); }, 30_000); res.once('finish', () => { clearTimeout(guard); handle.release(); }); res.once('close', () => { clearTimeout(guard); handle.release(); }); next(); }); }) .catch((err: Error) => next(err)); } catch (error) { console.error('Bot auth error:', error); res.status(401).json({ success: false, error: 'Ошибка аутентификации бота' }); } }; // Bot sends message with buttons app.post('/api/bot/messages', authenticateBot, validateRequest(botMessageSchema), async (req: AuthenticatedRequest & { bot?: Record }, res) => { try { const { taskId, message, buttons, replyToMessageId } = req.body; // Проверить что задача существует const task = await storage.getTask(taskId, req.organizationId!); if (!task) { return res.status(404).json({ success: false, error: 'Задача не найдена' }); } // Создать сообщение от бота const botMessage = await storage.createTaskMessage({ taskId, formId: task.formId, authorId: null, // null для бота botId: req.bot!.id, replyToMessageId: replyToMessageId || null, message, messageType: 'bot', mentionedUserIds: null, botButtons: buttons || null }, req.organizationId!); // Публикуем событие через SSE eventBus.publishEvent({ type: 'message_created', organizationId: req.organizationId!, taskId, data: { taskId, message: { ...botMessage, bot: { id: req.bot!.id, name: req.bot!.name, avatarUrl: req.bot!.avatarUrl } } } }); res.status(201).json({ success: true, message: 'Сообщение отправлено', messageId: botMessage.id }); } catch (error) { console.error('Bot send message error:', error); res.status(500).json({ success: false, error: 'Ошибка при отправке сообщения' }); } } ); // Bot callback handler - обработка нажатий на кнопки app.post('/api/bot/callback', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const { messageId, callbackData } = req.body; if (!messageId || !callbackData) { return res.status(400).json({ success: false, error: 'messageId и callbackData обязательны' }); } // Получить сообщение const message = await storage.getTaskMessage(messageId, req.organizationId!); if (!message || !message.botId) { return res.status(404).json({ success: false, error: 'Сообщение бота не найдено' }); } // Получить бота const bot = await storage.getBot(message.botId, req.organizationId!); if (!bot || !bot.webhookUrl || !bot.webhookEnabled) { return res.status(400).json({ success: false, error: 'Бот не настроен для обработки callback' }); } // Получить задачу const task = await storage.getTask(message.taskId, req.organizationId!); // Отправить callback в n8n webhook const callbackPayload = { event: 'button_callback', callbackData, messageId, taskId: message.taskId, task: task, user: { id: req.user!.id, firstName: req.user!.firstName, middleName: req.user!.middleName, lastName: req.user!.lastName, email: req.user!.email }, timestamp: new Date().toISOString() }; // Создаём подпись HMAC если есть secret let signature = ''; if (bot.webhookSecret) { const secret = decrypt(bot.webhookSecret); signature = crypto .createHmac('sha256', secret) .update(JSON.stringify(callbackPayload)) .digest('hex'); } // Отправляем в n8n await sendWebhook( bot.webhookUrl, callbackPayload, { 'X-Webhook-Signature': signature, 'X-Bot-Id': bot.id.toString(), }, { taskId: message.taskId, organizationId: req.organizationId!, botId: bot.id, eventType: 'button_callback', } ); res.json({ success: true, message: 'Callback обработан' }); } catch (error) { console.error('Bot callback error:', error); res.status(500).json({ success: false, error: 'Ошибка обработки callback' }); } } ); // Bot get task info app.get('/api/bot/tasks/:id', authenticateBot, async (req: AuthenticatedRequest & { bot?: Record }, res) => { try { const taskId = parseInt(req.params.id); if (isNaN(taskId)) { return res.status(400).json({ success: false, error: 'Некорректный ID задачи' }); } const task = await storage.getTask(taskId, req.organizationId!); if (!task) { return res.status(404).json({ success: false, error: 'Задача не найдена' }); } // Получить значения полей const fieldValues = await storage.getTaskFieldValues(taskId, req.organizationId!); // Получить сообщения const messages = await storage.getTaskMessages(taskId, req.organizationId!); res.json({ success: true, task: { ...task, fieldValues, messages } }); } catch (error) { console.error('Bot get task error:', error); res.status(500).json({ success: false, error: 'Ошибка при получении задачи' }); } } ); // Bot update task fields app.patch('/api/bot/tasks/:id/fields', authenticateBot, async (req: AuthenticatedRequest & { bot?: Record }, res) => { try { const taskId = parseInt(req.params.id); if (isNaN(taskId)) { return res.status(400).json({ success: false, error: 'Некорректный ID задачи' }); } const task = await storage.getTask(taskId, req.organizationId!); if (!task) { return res.status(404).json({ success: false, error: 'Задача не найдена' }); } const { fieldValues } = req.body; if (!fieldValues || typeof fieldValues !== 'object') { return res.status(400).json({ success: false, error: 'fieldValues должен быть объектом' }); } // Получить поля формы const formFields = await storage.getFormFields(task.formId, req.organizationId!); const fieldMap = new Map(formFields.map(f => [f.code, f])); // Получить старые значения для аудита const botOldValues = await storage.getTaskFieldValues(taskId, req.organizationId!); const botOldValueMap = new Map(botOldValues.map(v => [v.fieldId, v.value])); // Обновить значения полей for (const [code, value] of Object.entries(fieldValues)) { const field = fieldMap.get(code); if (!field) continue; try { await storage.updateTaskFieldValue(taskId, field.id, req.organizationId!, { value: value as string | number | boolean | null | Record }); } catch { // Если запись не существует, создаём await storage.createTaskFieldValue({ taskId, fieldId: field.id, formId: task.formId, value: value as string | number | boolean | null | Record }); } } // Audit log: bot field changes for (const [code, value] of Object.entries(fieldValues)) { const field = fieldMap.get(code); if (!field) continue; const oldVal = botOldValueMap.get(field.id); const newVal = value; const oldStr = oldVal === null || oldVal === undefined ? '' : String(oldVal); const newStr = newVal === null || newVal === undefined ? '' : String(newVal); if (oldStr !== newStr) { storage.addTaskAuditLog({ taskId, organizationId: req.organizationId!, action: 'field.changed', fieldId: field.id, fieldName: field.name, oldValue: oldVal ?? null, newValue: newVal as string | number | boolean | null, changedBy: null, changedByName: 'API / Бот', }).catch((auditErr: unknown) => { console.error('Audit log error:', auditErr); }); } } // Публикуем событие обновления задачи const updatedTask = await storage.getTask(taskId, req.organizationId!); eventBus.publishEvent({ type: 'task_updated', organizationId: req.organizationId!, data: { taskId, formId: task.formId, task: updatedTask } }); // Web Push: notify all offline task participants pushTaskUpdated({ taskId, organizationId: req.organizationId!, formId: task.formId, taskTitle: updatedTask?.title ?? task.title, actorId: 0, // bot update — no actor to exclude }).catch(() => {}); res.json({ success: true, message: 'Поля задачи обновлены' }); } catch (error) { console.error('Bot update task fields error:', error); res.status(500).json({ success: false, error: 'Ошибка при обновлении полей задачи' }); } } ); // Bot gets its messenger conversations (bot_direct only) app.get('/api/bot/conversations', authenticateBot, async (req: AuthenticatedRequest & { bot?: Record }, res) => { try { const convs = await db .select() .from(conversations) .where(and( eq(conversations.type, 'bot_direct'), eq(conversations.botId, req.bot!.id as number), eq(conversations.organizationId, req.organizationId!), )); const result = await Promise.all( convs.map(async (conv) => { const members = await db .select({ id: users.id, firstName: users.firstName, middleName: users.middleName, lastName: users.lastName, email: users.email, }) .from(conversationMembers) .innerJoin(users, eq(conversationMembers.userId, users.id)) .where(eq(conversationMembers.conversationId, conv.id)); return { id: conv.id, type: conv.type, createdAt: conv.createdAt, members, }; }) ); res.json({ success: true, conversations: result }); } catch (error) { console.error('Bot get conversations error:', error); res.status(500).json({ success: false, error: 'Ошибка при получении диалогов' }); } } ); // Bot gets messages from a messenger conversation (bot_direct only) app.get('/api/bot/conversations/:id/messages', authenticateBot, async (req: AuthenticatedRequest & { bot?: Record }, res) => { try { const convId = parseInt(req.params.id); if (isNaN(convId)) { return res.status(400).json({ success: false, error: 'Некорректный ID чата' }); } const [conv] = await db .select() .from(conversations) .where(and( eq(conversations.id, convId), eq(conversations.organizationId, req.organizationId!), eq(conversations.type, 'bot_direct'), eq(conversations.botId, req.bot!.id as number), )); if (!conv) { return res.status(404).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 conditions = [ eq(conversationMessages.conversationId, convId), eq(conversationMessages.isDeleted, false), ]; if (beforeId !== null && !isNaN(beforeId)) { conditions.push(lt(conversationMessages.id, beforeId)); } 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, 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; lastName: string } } | null = null; if (m.replyToId) { const [r] = await db .select({ id: conversationMessages.id, message: conversationMessages.message, authorId: conversationMessages.authorId, authorFirstName: users.firstName, 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 ?? '', lastName: r.authorLastName ?? '' }, }; } } const botInfo = m.botId ? botInfoMap.get(m.botId) ?? null : null; return { ...m, author: m.authorId ? { id: m.authorId, firstName: m.authorFirstName ?? '', lastName: m.authorLastName ?? '' } : null, bot: botInfo, replyTo, }; }) ); res.json({ success: true, messages: enrichedMsgs.reverse() }); } catch (error) { console.error('Bot get messenger messages error:', error); res.status(500).json({ success: false, error: 'Ошибка при получении сообщений' }); } } ); // Bot sends message to a messenger conversation (bot_direct only) app.post('/api/bot/conversations/:id/messages', authenticateBot, async (req: AuthenticatedRequest & { bot?: Record }, res) => { try { const convId = parseInt(req.params.id); if (isNaN(convId)) { return res.status(400).json({ success: false, error: 'Некорректный ID чата' }); } const { message, attachments, buttons } = req.body; const hasButtons = Array.isArray(buttons) && buttons.length > 0; if (!message?.trim() && (!attachments || attachments.length === 0) && !hasButtons) { return res.status(400).json({ success: false, error: 'Сообщение, вложения или кнопки обязательны' }); } const [conv] = await db .select() .from(conversations) .where(and( eq(conversations.id, convId), eq(conversations.organizationId, req.organizationId!), eq(conversations.type, 'bot_direct'), eq(conversations.botId, req.bot!.id as number), )); if (!conv) { return res.status(404).json({ success: false, error: 'Чат не найден или бот не является его участником' }); } const [newMsg] = await db .insert(conversationMessages) .values({ conversationId: convId, authorId: null, botId: req.bot!.id as number, message: message?.trim() || '', replyToId: null, mentionedUserIds: null, attachments: attachments?.length ? attachments : null, botButtons: hasButtons ? buttons : null, }) .returning(); const enriched = await enrichMessage(newMsg.id); const members = await getMembersForSSE(convId, req.organizationId!); for (const { userId: memberId, orgId: memberOrgId } of members) { eventBus.publishEvent({ type: 'conv_message_created', data: { conversationId: convId, message: enriched }, organizationId: memberOrgId, userId: memberId, }); } const botName = (req.bot!.name as string) || 'Бот'; const memberMuteRows = await db .select({ userId: conversationMembers.userId, mutedAt: conversationMembers.mutedAt, externalOrgId: conversationMembers.externalOrgId }) .from(conversationMembers) .where(eq(conversationMembers.conversationId, convId)); const memberOrgMap = new Map( memberMuteRows.map(r => [ r.userId, { orgId: r.externalOrgId ?? req.organizationId!, mutedAt: r.mutedAt ?? null }, ]) ); notificationService.notifyMessengerMessage({ conversationId: convId, authorId: -1, authorName: botName, chatName: botName, chatType: 'bot_direct', messageText: message?.trim() || '', mentionedUserIds: [], memberOrgMap, }).catch(err => console.error('[Bot Messenger Notify] Error:', err)); res.status(201).json({ success: true, message: enriched }); } catch (error) { console.error('Bot send messenger message error:', error); res.status(500).json({ success: false, error: 'Ошибка при отправке сообщения' }); } } ); }