perf(vpn-bot): фоновая подготовка подписки, оптимизация запросов, анимация кнопок
- validateTelemostRoom: таймаут 3 сек + кэш 10 мин - createVpnTask: транзакция + batch insert field values - prepareVpnSubscription: валидация и создание подписки в фоне при получении ссылки - sendVpnBotMessage: кэш botId, Promise.all для enrich/members/orgMap - processPlatformSelection: одно сообщение с файлом и подпиской вместо двух - Chat.tsx: нажатая кнопка становится зелёной галочкой, остальные disabled
This commit is contained in:
@@ -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<number, { id: number | null; expiresAt: number }>();
|
||||
|
||||
async function getVpnBotId(organizationId: number): Promise<number | null> {
|
||||
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<void> {
|
||||
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<number | null> {
|
||||
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<PreparedSubscription | { error: string }>;
|
||||
}
|
||||
|
||||
// Simple in-memory state for the platform-selection step.
|
||||
// Key = conversationId. TTL cleanup runs every 5 minutes.
|
||||
const pendingPlatform = new Map<number, { roomUrl: string; createdAt: number }>();
|
||||
const pendingVpnSession = new Map<number, PendingVpnSession>();
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user