Files
iistwin/server/routes/bot-api.routes.ts
Ильяс Султанов 1f5ecb6da4 fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
2026-07-07 21:03:40 +03:00

667 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { verifyAccessToken } 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. Обычный токен бота (role: 'bot') - получается через /api/bot/auth/login
// 2. Сервисный токен (type: 'bot_service') - передаётся в webhook при @mention
const authenticateBot = async (req: AuthenticatedRequest & { bot?: Record<string, unknown> }, 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);
// Попробуем сначала как обычный токен бота
let botId: number;
let organizationId: number;
try {
const decoded = verifyAccessToken(token);
if (decoded.appRole === 'bot') {
// Обычный токен бота (userId содержит botId)
botId = decoded.userId;
organizationId = decoded.organizationId;
} else {
throw new Error('Not a bot token');
}
} catch {
// Попробуем как сервисный токен
try {
const { verifyBotServiceToken } = await import('../utils/jwt');
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<string, unknown> }, 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<string, unknown> }, 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<string, unknown> }, 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<string, unknown>
});
} catch {
// Если запись не существует, создаём
await storage.createTaskFieldValue({
taskId,
fieldId: field.id,
formId: task.formId,
value: value as string | number | boolean | null | Record<string, unknown>
});
}
}
// 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<string, unknown> }, 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<string, unknown> }, 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,
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<number, { id: number; name: string; avatarUrl: string | null }>();
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<string, unknown> }, res) => {
try {
const convId = parseInt(req.params.id);
if (isNaN(convId)) {
return res.status(400).json({ success: false, error: 'Некорректный ID чата' });
}
const { message, attachments } = req.body;
if (!message?.trim() && (!attachments || attachments.length === 0)) {
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,
})
.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: 'Ошибка при отправке сообщения' });
}
}
);
}