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:
@@ -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" });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user