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

@@ -506,6 +506,7 @@ export function registerBotApiRoutes(app: import("express").Express): void {
replyToId: conversationMessages.replyToId,
mentionedUserIds: conversationMessages.mentionedUserIds,
attachments: conversationMessages.attachments,
botButtons: conversationMessages.botButtons,
createdAt: conversationMessages.createdAt,
updatedAt: conversationMessages.updatedAt,
isDeleted: conversationMessages.isDeleted,
@@ -587,9 +588,10 @@ export function registerBotApiRoutes(app: import("express").Express): void {
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 { 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
@@ -616,6 +618,7 @@ export function registerBotApiRoutes(app: import("express").Express): void {
replyToId: null,
mentionedUserIds: null,
attachments: attachments?.length ? attachments : null,
botButtons: hasButtons ? buttons : null,
})
.returning();

View File

@@ -2,6 +2,7 @@ import { db } from "../db";
import { conversations, conversationMembers, conversationMessages, users, bots } from "@shared/schema";
import { eq, and, desc, sql } from "drizzle-orm";
import { formatUserName } from "../utils/formatUserName";
import { eventBus } from "./shared";
export async function isMember(conversationId: number, userId: number): Promise<boolean> {
const [row] = await db
@@ -120,6 +121,7 @@ export async function enrichMessage(msgId: number) {
replyToId: conversationMessages.replyToId,
mentionedUserIds: conversationMessages.mentionedUserIds,
attachments: conversationMessages.attachments,
botButtons: conversationMessages.botButtons,
createdAt: conversationMessages.createdAt,
updatedAt: conversationMessages.updatedAt,
isDeleted: conversationMessages.isDeleted,
@@ -190,3 +192,21 @@ export async function getMembersForSSE(convId: number, conversationOrgId: number
orgId: r.externalOrgId ?? conversationOrgId,
})));
}
export async function publishConvTypingEvent(
convId: number,
botName: string,
organizationId: number,
isTyping: boolean,
) {
const members = await getMembersForSSE(convId, organizationId);
const type = isTyping ? 'conv_typing_started' : 'conv_typing_stopped';
for (const { userId: memberId, orgId: memberOrgId } of members) {
eventBus.publishEvent({
type,
data: { conversationId: convId, botName },
organizationId: memberOrgId,
userId: memberId,
});
}
}

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" });
}
}
);
}

View File

@@ -114,6 +114,15 @@ export class EventBus {
timestamp: Date.now(),
};
// Логируем chat-события для диагностики задержек/пропусков
const isChatEvent = event.type === 'conv_message_created' || event.type === 'conv_message_updated' ||
event.type === 'conv_message_deleted' || event.type === 'conv_read' || event.type === 'message_created' ||
event.type === 'message_read';
if (isChatEvent) {
const target = event.userId ? `user=${event.userId}` : `org=${event.organizationId}`;
console.log(`[SSE] publish ${event.type} ${target} conn=${this.connections.size} id=${bufferedEvent.id}`);
}
// Буферизуем событие для восстановления после reconnect
if (event.userId) {
this.pushToBuffer(this.userBuffers, event.userId, bufferedEvent);
@@ -121,6 +130,7 @@ export class EventBus {
this.pushToBuffer(this.orgBuffers, event.organizationId, bufferedEvent);
}
let delivered = 0;
this.connections.forEach((connection, id) => {
// Проверяем tenant isolation
if (event.organizationId && connection.organizationId !== event.organizationId) {
@@ -132,8 +142,13 @@ export class EventBus {
return;
}
this.writeEventToConnection(connection, bufferedEvent);
const ok = this.writeEventToConnection(connection, bufferedEvent);
if (ok) delivered++;
});
if (isChatEvent) {
console.log(`[SSE] delivered ${event.type} to ${delivered}/${this.connections.size} connection(s)`);
}
}
/**