feat(chat): typing indicator для ботов, кнопки ботов в мессенджере, VPN-бот с кнопками

- Добавлены SSE-события conv_typing_started/stopped и их обработка в Chat.tsx
- AI-бот публикует typing events перед/после генерации ответа
- Боты могут отправлять сообщения с кнопками через /api/bot/conversations/:id/messages
- Добавлен callback /api/messenger/messages/:id/callback для нажатий кнопок
- VPN-бот использует кнопки Android/iOS вместо текстового prompt
- Оптимистичное обновление unread count, ускоренный reportActiveChat, polling fallback,
  логирование SSE, push suppression для bot_direct
This commit is contained in:
2026-07-17 11:25:25 +03:00
parent 586a591773
commit 6bb89b4cf8
16 changed files with 492 additions and 60 deletions

View File

@@ -1,5 +1,6 @@
import { Router } from "express";
import { z } from "zod";
import crypto from "crypto";
import { db, openTenantCtx } from "../db";
import { conversations, conversationMembers, conversationMessages, users, bots, type Bot } from "@shared/schema";
import { eq, and, lt, gt, desc, sql, inArray } from "drizzle-orm";
@@ -11,7 +12,10 @@ import { formatUserName } from "../utils/formatUserName";
import { notificationService } from "../services/notification.service";
import { handleAiBotDirectMessage } from "../services/ai-bot.service";
import { checkBotAccess } from "../services/ai-bot.service";
import { handleVpnBotDirectMessage } from "../vpn/vpn-bot.service";
import { handleVpnBotDirectMessage, handleVpnBotCallback } from "../vpn/vpn-bot.service";
import { generateBotServiceToken } from "../utils/jwt";
import { sendWebhook } from "../utils/webhook";
import { decrypt } from "../crypto";
export function registerMessengerMessageRoutes(router: Router): void {
// GET /api/messenger/conversations/:id/messages
@@ -50,6 +54,7 @@ export function registerMessengerMessageRoutes(router: Router): void {
replyToId: conversationMessages.replyToId,
mentionedUserIds: conversationMessages.mentionedUserIds,
attachments: conversationMessages.attachments,
botButtons: conversationMessages.botButtons,
createdAt: conversationMessages.createdAt,
updatedAt: conversationMessages.updatedAt,
isDeleted: conversationMessages.isDeleted,
@@ -507,4 +512,125 @@ export function registerMessengerMessageRoutes(router: Router): void {
}
}
);
// POST /api/messenger/messages/:id/callback — handle bot button click in messenger
router.post("/api/messenger/messages/:id/callback",
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const userId = req.user!.id;
const messageId = parseInt(req.params.id);
if (isNaN(messageId)) {
return res.status(400).json({ success: false, error: "Некорректный ID сообщения" });
}
const { callbackData } = req.body;
if (!callbackData || typeof callbackData !== "string") {
return res.status(400).json({ success: false, error: "callbackData обязателен" });
}
const [msg] = await db
.select({
id: conversationMessages.id,
conversationId: conversationMessages.conversationId,
botId: conversationMessages.botId,
})
.from(conversationMessages)
.where(eq(conversationMessages.id, messageId));
if (!msg) {
return res.status(404).json({ success: false, error: "Сообщение не найдено" });
}
if (!msg.botId) {
return res.status(400).json({ success: false, error: "Сообщение не от бота" });
}
if (!await isMember(msg.conversationId, userId)) {
return res.status(403).json({ success: false, error: "Нет доступа к диалогу" });
}
const [bot] = await db
.select()
.from(bots)
.where(and(eq(bots.id, msg.botId), eq(bots.organizationId, req.organizationId!)));
if (!bot) {
return res.status(404).json({ success: false, error: "Бот не найден" });
}
// Inline handler for built-in VPN bot (no external webhook required)
if (bot.type === 'vpn') {
await handleVpnBotCallback({
bot,
conversationId: msg.conversationId,
messageId: msg.id,
callbackData,
user: req.user!,
organizationId: req.organizationId!,
});
return res.json({ success: true, message: "Callback обработан" });
}
if (!bot.webhookUrl || !bot.webhookEnabled) {
return res.status(400).json({ success: false, error: "Бот не настроен для обработки callback" });
}
const botAccessToken = generateBotServiceToken(bot.id, req.organizationId!);
const callbackPayload = {
event: "bot_direct_button_callback",
timestamp: new Date().toISOString(),
organizationId: req.organizationId,
data: {
conversationId: msg.conversationId,
messageId: msg.id,
callbackData,
bot: {
id: bot.id,
name: bot.name,
accessToken: botAccessToken,
},
user: {
id: req.user!.id,
firstName: req.user!.firstName,
middleName: req.user!.middleName,
lastName: req.user!.lastName,
email: req.user!.email,
},
},
};
let signature = "";
if (bot.webhookSecret) {
const secret = decrypt(bot.webhookSecret);
signature = crypto
.createHmac("sha256", secret)
.update(JSON.stringify(callbackPayload))
.digest("hex");
}
await sendWebhook(
bot.webhookUrl,
callbackPayload,
{
"X-Webhook-Signature": signature,
"X-Bot-Id": bot.id.toString(),
"X-Organization-Id": req.organizationId!.toString(),
},
{
conversationId: msg.conversationId,
organizationId: req.organizationId!,
botId: bot.id,
eventType: "bot_direct_button_callback",
}
);
res.json({ success: true, message: "Callback обработан" });
} catch (err) {
console.error("Messenger callback error:", err);
res.status(500).json({ success: false, error: "Ошибка обработки callback" });
}
}
);
}