- extractFileKey понимает абсолютные URL (по pathname) — превью работает для уже записанных файлов - normalizeFileUrl: абсолютные self-URL нормализуются в относительные при записи file-полей (REST field-values, MCP update_task_fields/upload-инструменты, bot-api, create task)
669 lines
24 KiB
TypeScript
669 lines
24 KiB
TypeScript
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";
|
||
import { normalizeFileFieldUrls } from "../utils/upload";
|
||
|
||
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<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);
|
||
|
||
// Принимаются два типа токенов:
|
||
// 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<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;
|
||
|
||
// Нормализация self-URL в file-полях: абсолютные https://.../api/files/<key> → относительные
|
||
const storedValue = field.type === 'file' ? normalizeFileFieldUrls(value) : value;
|
||
try {
|
||
await storage.updateTaskFieldValue(taskId, field.id, req.organizationId!, {
|
||
value: storedValue as string | number | boolean | null | Record<string, unknown>
|
||
});
|
||
} catch {
|
||
// Если запись не существует, создаём
|
||
await storage.createTaskFieldValue({
|
||
taskId,
|
||
fieldId: field.id,
|
||
formId: task.formId,
|
||
value: storedValue 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,
|
||
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<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, 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: 'Ошибка при отправке сообщения' });
|
||
}
|
||
}
|
||
);
|
||
|
||
}
|