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, sql } 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. **Важно:** после создания **выйди** из встречи, а не **завершай** её. Тогда ссылка останется активной и будет работать для VPN. 4. Пришли сюда ссылку на встречу. Примеры того, что подходит: • 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(); 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, organizationId: number, attachments?: Attachment[], buttons?: BotButton[], ): Promise { 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; } const pendingVpnSession = new Map(); 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 { 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 { 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 { 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, ); } // ── Room expiry notifications ───────────────────────────────────────────────── async function findVpnBotConversationId( organizationId: number, userId: number, ): Promise { const botId = await getVpnBotId(organizationId); if (!botId) return null; const rows = await db.execute(sql` SELECT c.id FROM conversations c JOIN conversation_members cm ON cm.conversation_id = c.id WHERE c.type = 'bot_direct' AND c.bot_id = ${botId} AND c.organization_id = ${organizationId} AND cm.user_id = ${userId} LIMIT 1 `); interface Row { id: number } const row = rows.rows[0] as unknown as Row | undefined; return row?.id ?? null; } export async function notifyVpnRoomExpired( organizationId: number, userId: number, roomUrl: string, ): Promise { const conversationId = await findVpnBotConversationId(organizationId, userId); if (!conversationId) { console.warn(`[VPN Bot] no bot_direct conversation found for user ${userId} in org ${organizationId}`); return; } await sendVpnBotMessage( conversationId, `⚠️ Ссылка на встречу Яндекс Телемост больше недействительна:\n${roomUrl}\n\nСоздайте новую встречу в Яндекс Телемост (выйдите из неё, не завершая) и пришлите новую ссылку.`, organizationId, ); }