From 9f1c2e5b4586084bab6a27e7ab0c54dde9fd79de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=D1=81=20=D0=A1=D1=83=D0=BB=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2?= Date: Fri, 17 Jul 2026 12:38:33 +0300 Subject: [PATCH] =?UTF-8?q?perf(vpn-bot):=20=D1=84=D0=BE=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D1=8F=20=D0=BF=D0=BE=D0=B4=D0=B3=D0=BE=D1=82=D0=BE=D0=B2?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8=D1=81=D0=BA=D0=B8?= =?UTF-8?q?,=20=D0=BE=D0=BF=D1=82=D0=B8=D0=BC=D0=B8=D0=B7=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=B7=D0=B0=D0=BF=D1=80=D0=BE=D1=81=D0=BE=D0=B2?= =?UTF-8?q?,=20=D0=B0=D0=BD=D0=B8=D0=BC=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BA?= =?UTF-8?q?=D0=BD=D0=BE=D0=BF=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - validateTelemostRoom: таймаут 3 сек + кэш 10 мин - createVpnTask: транзакция + batch insert field values - prepareVpnSubscription: валидация и создание подписки в фоне при получении ссылки - sendVpnBotMessage: кэш botId, Promise.all для enrich/members/orgMap - processPlatformSelection: одно сообщение с файлом и подпиской вместо двух - Chat.tsx: нажатая кнопка становится зелёной галочкой, остальные disabled --- client/src/pages/Chat.tsx | 47 +++++++--- server/vpn/vpn-bot.service.ts | 161 +++++++++++++++++++++------------- server/vpn/vpn.db.ts | 48 +++++----- server/vpn/vpn.service.ts | 71 +++++++++++++-- 4 files changed, 224 insertions(+), 103 deletions(-) diff --git a/client/src/pages/Chat.tsx b/client/src/pages/Chat.tsx index 3960977..bfdb830 100644 --- a/client/src/pages/Chat.tsx +++ b/client/src/pages/Chat.tsx @@ -146,7 +146,7 @@ function groupButtonsByRow(buttons: BotButton[]): BotButton[][] { function MessageBubble({ msg, isOwn, onReply, onEdit, onDelete, reactions, poll, onReactionsChange, onPollChange, - onOpenUserChat, onOpenBotChat, onButtonClick, + onOpenUserChat, onOpenBotChat, onButtonClick, pressedButtons, }: { msg: ConvMessage; isOwn: boolean; @@ -160,6 +160,7 @@ function MessageBubble({ onOpenUserChat?: (userId: number) => void; onOpenBotChat?: (botId: number) => void; onButtonClick?: (m: ConvMessage, button: BotButton) => void; + pressedButtons?: Set; }) { const [pickerOpen, setPickerOpen] = useState(false); const isEdited = msg.updatedAt && msg.updatedAt !== msg.createdAt; @@ -313,17 +314,26 @@ function MessageBubble({
{groupButtonsByRow(msg.botButtons).map((row, rowIdx) => (
- {row.map((btn, btnIdx) => ( - - ))} + {row.map((btn, btnIdx) => { + const btnKey = `${msg.id}:${btn.callbackData}`; + const isPressed = pressedButtons?.has(btnKey) ?? false; + return ( + + ); + })}
))}
@@ -1000,6 +1010,8 @@ export default function ChatPage() { // Typing indicator for bots const [typingBots, setTypingBots] = useState>(new Set()); const typingTimersRef = useRef>(new Map()); + // Pressed bot buttons (messageId:callbackData) + const [pressedButtons, setPressedButtons] = useState>(new Set()); // File attachments const [pendingFiles, setPendingFiles] = useState([]); const fileInputRef = useRef(null); @@ -1406,10 +1418,11 @@ export default function ChatPage() { }; useEffect(() => { - // Clear all typing indicators when switching conversations + // Clear all typing indicators and pressed buttons when switching conversations typingTimersRef.current.forEach(timer => clearTimeout(timer)); typingTimersRef.current.clear(); setTypingBots(new Set()); + setPressedButtons(new Set()); }, [activeConvId]); // ── SSE ─────────────────────────────────────────────────────────────────────── @@ -2402,8 +2415,16 @@ export default function ChatPage() { openBotDirectMutation.mutate(botId); }} onButtonClick={(m, btn) => { + const btnKey = `${m.id}:${btn.callbackData}`; + setPressedButtons(prev => { + if (prev.has(btnKey)) return prev; + const next = new Set(prev); + next.add(btnKey); + return next; + }); botButtonCallbackMutation.mutate({ messageId: m.id, callbackData: btn.callbackData }); }} + pressedButtons={pressedButtons} /> ))} diff --git a/server/vpn/vpn-bot.service.ts b/server/vpn/vpn-bot.service.ts index 27adb35..b5578d7 100644 --- a/server/vpn/vpn-bot.service.ts +++ b/server/vpn/vpn-bot.service.ts @@ -1,12 +1,12 @@ import fs from "fs"; import path from "path"; import { db } from "../db"; -import { conversationMessages, conversationMembers, users, bots, type Bot, type User } from "@shared/schema"; +import { conversationMessages, conversationMembers, bots, type Bot, type User } from "@shared/schema"; import { eq, and } from "drizzle-orm"; import { eventBus } from "../routes/shared"; import { enrichMessage, getMembersForSSE } from "../routes/messenger.helpers"; import { notificationService } from "../services/notification.service"; -import { getOrCreateVpnSubscription, extractRoomUrl } from "./vpn.service"; +import { prepareVpnSubscription, extractRoomUrl, type PreparedSubscription } from "./vpn.service"; import { VPN_BOT_LOGIN } from "./vpn.config"; const APPS_BASE_URL = process.env.APP_URL @@ -46,6 +46,29 @@ type Attachment = { url: string; name: string; size: number; mimeType?: string } type BotButton = { text: string; callbackData: string; style?: 'primary' | 'secondary' | 'destructive' | 'outline'; row?: number }; +// ── Bot ID cache ───────────────────────────────────────────────────────────── + +const botIdCache = new Map(); + +async function getVpnBotId(organizationId: number): Promise { + const cached = botIdCache.get(organizationId); + if (cached && cached.expiresAt > Date.now()) { + return cached.id; + } + + const [bot] = await db + .select({ id: bots.id }) + .from(bots) + .where(and(eq(bots.organizationId, organizationId), eq(bots.login, VPN_BOT_LOGIN))) + .limit(1); + + const id = bot?.id ?? null; + botIdCache.set(organizationId, { id, expiresAt: Date.now() + 5 * 60 * 1000 }); + return id; +} + +// ── Message sending ─────────────────────────────────────────────────────────── + export async function sendVpnBotMessage( conversationId: number, text: string, @@ -53,12 +76,14 @@ export async function sendVpnBotMessage( attachments?: Attachment[], buttons?: BotButton[], ): Promise { + const botId = await getVpnBotId(organizationId); + const [msg] = await db .insert(conversationMessages) .values({ conversationId, authorId: null, - botId: (await getVpnBotId(organizationId)) ?? null, + botId: botId ?? null, message: text.trim(), replyToId: null, mentionedUserIds: null, @@ -67,8 +92,15 @@ export async function sendVpnBotMessage( }) .returning(); - const enriched = await enrichMessage(msg.id); - const members = await getMembersForSSE(conversationId, organizationId); + // Run independent reads in parallel + const [enriched, members, memberMuteRows] = await Promise.all([ + enrichMessage(msg.id), + getMembersForSSE(conversationId, organizationId), + db + .select({ userId: conversationMembers.userId, mutedAt: conversationMembers.mutedAt, externalOrgId: conversationMembers.externalOrgId }) + .from(conversationMembers) + .where(eq(conversationMembers.conversationId, conversationId)), + ]); for (const { userId: memberId, orgId: memberOrgId } of members) { eventBus.publishEvent({ @@ -79,11 +111,6 @@ export async function sendVpnBotMessage( }); } - const memberMuteRows = await db - .select({ userId: conversationMembers.userId, mutedAt: conversationMembers.mutedAt, externalOrgId: conversationMembers.externalOrgId }) - .from(conversationMembers) - .where(eq(conversationMembers.conversationId, conversationId)); - const memberOrgMap = new Map( memberMuteRows.map(r => [r.userId, { orgId: r.externalOrgId ?? organizationId, mutedAt: r.mutedAt ?? null }]), ); @@ -100,26 +127,27 @@ export async function sendVpnBotMessage( }).catch(err => console.error("[VPN Bot] notify error:", err)); } -async function getVpnBotId(organizationId: number): Promise { - const [bot] = await db - .select({ id: bots.id }) - .from(bots) - .where(and(eq(bots.organizationId, organizationId), eq(bots.login, VPN_BOT_LOGIN))) - .limit(1); - return bot?.id ?? null; +// ── Pending VPN session (in-memory) ─────────────────────────────────────────── + +interface PendingVpnSession { + roomUrl: string; + user: User; + organizationId: number; + createdAt: number; + promise: Promise; } -// Simple in-memory state for the platform-selection step. -// Key = conversationId. TTL cleanup runs every 5 minutes. -const pendingPlatform = new Map(); +const pendingVpnSession = new Map(); -function cleanPending() { +function cleanPendingSessions() { const cutoff = Date.now() - 10 * 60 * 1000; - for (const [key, value] of pendingPlatform.entries()) { - if (value.createdAt < cutoff) pendingPlatform.delete(key); + for (const [key, value] of pendingVpnSession.entries()) { + if (value.createdAt < cutoff) pendingVpnSession.delete(key); } } -setInterval(cleanPending, 5 * 60 * 1000); +setInterval(cleanPendingSessions, 5 * 60 * 1000); + +// ── Helpers ───────────────────────────────────────────────────────────────── function detectPlatform(text: string): "android" | "ios" | null { const lower = text.toLowerCase(); @@ -150,6 +178,8 @@ function getFileAttachment(filename: string): Attachment { }; } +// ── Direct message handling ─────────────────────────────────────────────────── + export async function handleVpnBotDirectMessage({ bot, conversationId, @@ -176,15 +206,15 @@ export async function handleVpnBotDirectMessage({ } // Continue platform-selection dialog (text fallback) - const pending = pendingPlatform.get(conversationId); + const pending = pendingVpnSession.get(conversationId); if (pending) { const platform = detectPlatform(text); if (!platform) { await sendVpnBotMessage(conversationId, PLATFORM_PROMPT, organizationId, undefined, PLATFORM_BUTTONS); return; } - pendingPlatform.delete(conversationId); - await processPlatformSelection({ conversationId, user, organizationId, roomUrl: pending.roomUrl, platform }); + pendingVpnSession.delete(conversationId); + await processPlatformSelection({ conversationId, organizationId, session: pending, platform }); return; } @@ -195,55 +225,60 @@ export async function handleVpnBotDirectMessage({ } // Start platform-selection dialog with buttons - pendingPlatform.set(conversationId, { roomUrl, createdAt: Date.now() }); + // Run heavy work (validation + subscription creation) in background + const session: PendingVpnSession = { + roomUrl, + user, + organizationId, + createdAt: Date.now(), + promise: prepareVpnSubscription(organizationId, user.id, formatUserName(user), roomUrl) + .catch((err: any) => { + console.error("[VPN Bot] background prepare error:", err); + return { error: err instanceof Error ? err.message : "Произошла ошибка. Попробуйте позже." }; + }), + }; + pendingVpnSession.set(conversationId, session); + await sendVpnBotMessage(conversationId, PLATFORM_PROMPT, organizationId, undefined, PLATFORM_BUTTONS); } +// ── Platform selection processing ───────────────────────────────────────────── + async function processPlatformSelection({ conversationId, - user, organizationId, - roomUrl, + session, platform, }: { conversationId: number; - user: User; organizationId: number; - roomUrl: string; + session: PendingVpnSession; platform: "android" | "ios"; }): Promise { - try { - const userName = formatUserName(user); - const result = await getOrCreateVpnSubscription(organizationId, user.id, userName, roomUrl); + const prepared = await session.promise; - if (platform === "android") { - await sendVpnBotMessage( - conversationId, - "Установи приложение olcbox из прикреплённого APK, затем добавь подписку по ссылке ниже.", - organizationId, - [getFileAttachment(ANDROID_FILE)], - ); - } else { - await sendVpnBotMessage( - conversationId, - `${IOS_WARNING}\n\nПосле установки добавь подписку по ссылке ниже.`, - organizationId, - [getFileAttachment(IOS_FILE)], - ); - } - - const subscriptionText = result.isNew - ? `Подписка готова:\n${result.subscriptionUrl}\n\nTelegram и WhatsApp пойдут через латвийский сервер, российские сайты — напрямую.\nСсылка привязана к первому устройству, на котором её активируют.` - : `У вас уже есть подписка для этой комнаты:\n${result.subscriptionUrl}`; - - await sendVpnBotMessage(conversationId, subscriptionText, organizationId); - } catch (err: any) { - console.error("[VPN Bot] handle platform selection error:", err); - const userMessage = err instanceof Error ? err.message : "Произошла ошибка. Попробуйте позже."; - await sendVpnBotMessage(conversationId, userMessage, organizationId); + if ("error" in prepared) { + await sendVpnBotMessage(conversationId, prepared.error, organizationId); + return; } + + const attachment = platform === "android" + ? getFileAttachment(ANDROID_FILE) + : getFileAttachment(IOS_FILE); + + const subscriptionText = prepared.isNew + ? `Подписка готова:\n${prepared.subscriptionUrl}\n\nTelegram и WhatsApp пойдут через латвийский сервер, российские сайты — напрямую.\nСсылка привязана к первому устройству, на котором её активируют.` + : `У вас уже есть подписка для этой комнаты:\n${prepared.subscriptionUrl}`; + + const messageText = platform === "android" + ? `Установи приложение olcbox из прикреплённого APK, затем добавь подписку по ссылке ниже.\n\n${subscriptionText}` + : `${IOS_WARNING}\n\nПосле установки добавь подписку по ссылке ниже.\n\n${subscriptionText}`; + + await sendVpnBotMessage(conversationId, messageText, organizationId, [attachment]); } +// ── Button callback handling ────────────────────────────────────────────────── + export async function handleVpnBotCallback({ bot, conversationId, @@ -260,7 +295,7 @@ export async function handleVpnBotCallback({ organizationId: number; }): Promise { if (callbackData === "platform:android" || callbackData === "platform:ios") { - const pending = pendingPlatform.get(conversationId); + const pending = pendingVpnSession.get(conversationId); if (!pending) { await sendVpnBotMessage( conversationId, @@ -270,8 +305,8 @@ export async function handleVpnBotCallback({ return; } const platform = callbackData === "platform:android" ? "android" : "ios"; - pendingPlatform.delete(conversationId); - await processPlatformSelection({ conversationId, user, organizationId, roomUrl: pending.roomUrl, platform }); + pendingVpnSession.delete(conversationId); + await processPlatformSelection({ conversationId, organizationId, session: pending, platform }); return; } diff --git a/server/vpn/vpn.db.ts b/server/vpn/vpn.db.ts index 57809be..0e5700f 100644 --- a/server/vpn/vpn.db.ts +++ b/server/vpn/vpn.db.ts @@ -250,28 +250,34 @@ export async function createVpnTask( const cache = await getVpnFormCache(input.organizationId); const statusId = getStatusId(cache, input.statusName); - const task = await storage.createTask({ - organizationId: input.organizationId, - formId: VPN_FORM_ID, - title: input.title, - currentStatusId: statusId, - createdBy: input.createdBy, - completedAt: null, - dueDate: null, + return db.transaction(async (tx) => { + const [task] = await tx + .insert(tasks) + .values({ + organizationId: input.organizationId, + formId: VPN_FORM_ID, + title: input.title, + currentStatusId: statusId, + createdBy: input.createdBy, + isCompleted: false, + }) + .returning(); + + const fieldInserts = Object.entries(input.fields) + .filter(([, value]) => value !== undefined && value !== null) + .map(([code, value]) => ({ + taskId: task.id, + fieldId: getFieldId(cache, code), + formId: VPN_FORM_ID, + value: value as any, + })); + + if (fieldInserts.length > 0) { + await tx.insert(taskFieldValues).values(fieldInserts); + } + + return task; }); - - for (const [code, value] of Object.entries(input.fields)) { - if (value === undefined || value === null) continue; - const fieldId = getFieldId(cache, code); - await storage.createTaskFieldValue({ - taskId: task.id, - fieldId, - formId: VPN_FORM_ID, - value, - }); - } - - return task; } export async function updateVpnTaskFieldMap( diff --git a/server/vpn/vpn.service.ts b/server/vpn/vpn.service.ts index 6a9cceb..b5d05bd 100644 --- a/server/vpn/vpn.service.ts +++ b/server/vpn/vpn.service.ts @@ -72,7 +72,14 @@ export function extractRoomId(roomUrl: string): string { return roomUrl.slice(idx + 3); } +const telemostValidationCache = new Map(); + export async function validateTelemostRoom(roomUrl: string): Promise { + const cached = telemostValidationCache.get(roomUrl); + if (cached && cached.expiresAt > Date.now()) { + return cached.valid; + } + try { const encoded = encodeURIComponent(roomUrl); const url = `${VPN_TELEMOST_API_BASE}/conferences/${encoded}/connection?next_gen_media_platform_allowed=true&display_name=VPN-Bot&waiting_room_supported=true`; @@ -87,20 +94,49 @@ export async function validateTelemostRoom(roomUrl: string): Promise { Origin: 'https://telemost.yandex.ru', Referer: 'https://telemost.yandex.ru/', }, + signal: AbortSignal.timeout(3000), }); - return resp.status === 200; + const valid = resp.status === 200; + telemostValidationCache.set(roomUrl, { valid, expiresAt: Date.now() + 10 * 60 * 1000 }); + return valid; } catch (err) { console.error('[VPN] Telemost validation error:', err); + telemostValidationCache.set(roomUrl, { valid: false, expiresAt: Date.now() + 60 * 1000 }); return false; } } -export async function getOrCreateVpnSubscription( +export interface PreparedSubscription { + subscriptionUrl: string; + isNew: boolean; + taskId?: number; + roomId: string; + cryptoKey?: string; +} + +export async function getExistingVpnSubscription( + organizationId: number, + roomUrl: string, +): Promise { + const existing = await findVpnTaskByRoomUrl(roomUrl, organizationId); + if (!existing) return null; + const token = existing.values.subscription_token as string | undefined; + if (!token) return null; + return { + subscriptionUrl: buildSubscriptionUrl(token), + isNew: false, + taskId: existing.task.id, + roomId: extractRoomId(roomUrl), + cryptoKey: existing.values.crypto_key as string | undefined, + }; +} + +export async function prepareVpnSubscription( organizationId: number, userId: number, userName: string, roomUrl: string, -): Promise<{ subscriptionUrl: string; isNew: boolean }> { +): Promise { const existing = await findVpnTaskByRoomUrl(roomUrl, organizationId); if (existing && existing.task.createdBy !== userId) { throw new VpnError('Эта комната уже используется другим сотрудником', 'ROOM_IN_USE'); @@ -108,7 +144,13 @@ export async function getOrCreateVpnSubscription( if (existing) { const token = existing.values.subscription_token as string | undefined; if (!token) throw new VpnError('Подписка повреждена, обратитесь к администратору', 'BROKEN'); - return { subscriptionUrl: buildSubscriptionUrl(token), isNew: false }; + return { + subscriptionUrl: buildSubscriptionUrl(token), + isNew: false, + taskId: existing.task.id, + roomId: extractRoomId(roomUrl), + cryptoKey: existing.values.crypto_key as string | undefined, + }; } const roomId = extractRoomId(roomUrl); @@ -125,7 +167,7 @@ export async function getOrCreateVpnSubscription( const expiresAt = new Date(); expiresAt.setDate(expiresAt.getDate() + DEFAULT_SUBSCRIPTION_DAYS); - await createVpnTask({ + const task = await createVpnTask({ organizationId, createdBy: userId, title: `VPN ${userName || `#${userId}`} / ${roomId}`, @@ -144,7 +186,24 @@ export async function getOrCreateVpnSubscription( }, }); - return { subscriptionUrl: buildSubscriptionUrl(token), isNew: true }; + return { + subscriptionUrl: buildSubscriptionUrl(token), + isNew: true, + taskId: task.id, + roomId, + cryptoKey, + }; +} + +/** @deprecated Use prepareVpnSubscription instead */ +export async function getOrCreateVpnSubscription( + organizationId: number, + userId: number, + userName: string, + roomUrl: string, +): Promise<{ subscriptionUrl: string; isNew: boolean }> { + const result = await prepareVpnSubscription(organizationId, userId, userName, roomUrl); + return { subscriptionUrl: result.subscriptionUrl, isNew: result.isNew }; } export async function fetchSubscription(token: string, hwid: string | undefined): Promise {