Files
iistwin/server/vpn/vpn-bot.service.ts
Ильяс Султанов 9f1c2e5b45 perf(vpn-bot): фоновая подготовка подписки, оптимизация запросов, анимация кнопок
- validateTelemostRoom: таймаут 3 сек + кэш 10 мин
- createVpnTask: транзакция + batch insert field values
- prepareVpnSubscription: валидация и создание подписки в фоне при получении ссылки
- sendVpnBotMessage: кэш botId, Promise.all для enrich/members/orgMap
- processPlatformSelection: одно сообщение с файлом и подпиской вместо двух
- Chat.tsx: нажатая кнопка становится зелёной галочкой, остальные disabled
2026-07-17 12:38:33 +03:00

319 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import fs from "fs";
import path from "path";
import { db } from "../db";
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 { prepareVpnSubscription, extractRoomUrl, type PreparedSubscription } from "./vpn.service";
import { VPN_BOT_LOGIN } from "./vpn.config";
const APPS_BASE_URL = process.env.APP_URL
? `${process.env.APP_URL.replace(/\/$/, "")}/apps`
: "https://iistwin.ru/apps";
const ANDROID_FILE = "Olcbox-1.0.112-android-release.apk";
const IOS_FILE = "Olcbox-1.0.112-ios-unsigned.ipa";
const INSTRUCTIONS_TEXT = `Привет! Для получения корпоративного VPN:
1. Открой Яндекс Телемост (приложение или https://telemost.yandex.ru).
2. Создай новую видеовстречу.
3. Пришли сюда ссылку на встречу. Примеры того, что подходит:
• https://telemost.yandex.ru/j/abc123def456
• telemost.yandex.ru/j/abc123def456
• abc123def456
Можно прислать и сопроводительный текст — я сам найду ссылку.`;
const PLATFORM_PROMPT = `Ссылка принята. Выбери платформу, и я пришлю подходящий файл приложения olcbox.`;
const PLATFORM_BUTTONS: BotButton[] = [
{ text: "Android", callbackData: "platform:android", style: "primary" },
{ text: "iPhone (iOS)", callbackData: "platform:ios", style: "secondary" },
];
const IOS_WARNING = `⚠️ Файл для iOS не подписан. Как его установить на iPhone — честно, не знаю, придётся разбираться самому (AltStore, enterprise-сертификат и т.п.).`;
function formatUserName(user: User): string {
const parts = [user.firstName, user.lastName].filter(Boolean);
return parts.join(" ") || user.email || "Сотрудник";
}
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,
organizationId: number,
attachments?: Attachment[],
buttons?: BotButton[],
): Promise<void> {
const botId = await getVpnBotId(organizationId);
const [msg] = await db
.insert(conversationMessages)
.values({
conversationId,
authorId: null,
botId: botId ?? null,
message: text.trim(),
replyToId: null,
mentionedUserIds: null,
attachments: attachments?.length ? attachments : null,
botButtons: buttons?.length ? buttons : null,
})
.returning();
// 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({
type: "conv_message_created",
data: { conversationId, message: enriched },
organizationId: memberOrgId,
userId: memberId,
});
}
const memberOrgMap = new Map(
memberMuteRows.map(r => [r.userId, { orgId: r.externalOrgId ?? organizationId, mutedAt: r.mutedAt ?? null }]),
);
notificationService.notifyMessengerMessage({
conversationId,
authorId: -1,
authorName: "ВПН бот",
chatName: "ВПН бот",
chatType: "bot_direct",
messageText: text.trim(),
mentionedUserIds: [],
memberOrgMap,
}).catch(err => console.error("[VPN Bot] notify error:", err));
}
// ── Pending VPN session (in-memory) ───────────────────────────────────────────
interface PendingVpnSession {
roomUrl: string;
user: User;
organizationId: number;
createdAt: number;
promise: Promise<PreparedSubscription | { error: string }>;
}
const pendingVpnSession = new Map<number, PendingVpnSession>();
function cleanPendingSessions() {
const cutoff = Date.now() - 10 * 60 * 1000;
for (const [key, value] of pendingVpnSession.entries()) {
if (value.createdAt < cutoff) pendingVpnSession.delete(key);
}
}
setInterval(cleanPendingSessions, 5 * 60 * 1000);
// ── Helpers ─────────────────────────────────────────────────────────────────
function detectPlatform(text: string): "android" | "ios" | null {
const lower = text.toLowerCase();
if (/\b(android|андроид|samsung|xiaomi|pixel|huawei|honор|poco|redmi|galaxy)\b/i.test(lower)) {
return "android";
}
if (/\b(iphone|ios|айфон|ipad|apple)\b/i.test(lower)) {
return "ios";
}
return null;
}
function getFileAttachment(filename: string): Attachment {
const filePath = path.join("/app/apps", filename);
let size = 0;
try {
size = fs.statSync(filePath).size;
} catch (e) {
console.error(`[VPN Bot] could not stat ${filePath}:`, e);
}
const ext = path.extname(filename).toLowerCase();
const mimeType = ext === ".apk" ? "application/vnd.android.package-archive" : "application/octet-stream";
return {
url: `${APPS_BASE_URL}/${filename}`,
name: filename,
size,
mimeType,
};
}
// ── Direct message handling ───────────────────────────────────────────────────
export async function handleVpnBotDirectMessage({
bot,
conversationId,
message,
user,
organizationId,
}: {
bot: Bot;
conversationId: number;
message: string;
user: User;
organizationId: number;
}): Promise<void> {
const text = message.trim();
const lower = text.toLowerCase();
if (lower === "статус" || lower === "status") {
await sendVpnBotMessage(
conversationId,
"Статус подписки пока не реализован. Пришлите ссылку на встречу для получения подписки.",
organizationId,
);
return;
}
// Continue platform-selection dialog (text fallback)
const pending = pendingVpnSession.get(conversationId);
if (pending) {
const platform = detectPlatform(text);
if (!platform) {
await sendVpnBotMessage(conversationId, PLATFORM_PROMPT, organizationId, undefined, PLATFORM_BUTTONS);
return;
}
pendingVpnSession.delete(conversationId);
await processPlatformSelection({ conversationId, organizationId, session: pending, platform });
return;
}
const roomUrl = extractRoomUrl(text);
if (!roomUrl) {
await sendVpnBotMessage(conversationId, INSTRUCTIONS_TEXT, organizationId);
return;
}
// Start platform-selection dialog with buttons
// 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,
organizationId,
session,
platform,
}: {
conversationId: number;
organizationId: number;
session: PendingVpnSession;
platform: "android" | "ios";
}): Promise<void> {
const prepared = await session.promise;
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,
messageId,
callbackData,
user,
organizationId,
}: {
bot: Bot;
conversationId: number;
messageId: number;
callbackData: string;
user: User;
organizationId: number;
}): Promise<void> {
if (callbackData === "platform:android" || callbackData === "platform:ios") {
const pending = pendingVpnSession.get(conversationId);
if (!pending) {
await sendVpnBotMessage(
conversationId,
"Сессия выбора платформы истекла. Пришли ссылку на встречу заново.",
organizationId,
);
return;
}
const platform = callbackData === "platform:android" ? "android" : "ios";
pendingVpnSession.delete(conversationId);
await processPlatformSelection({ conversationId, organizationId, session: pending, platform });
return;
}
await sendVpnBotMessage(
conversationId,
`Неизвестное действие: ${callbackData}`,
organizationId,
);
}