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 { 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 { 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 }; export async function sendVpnBotMessage( conversationId: number, text: string, organizationId: number, attachments?: Attachment[], buttons?: BotButton[], ): Promise { const [msg] = await db .insert(conversationMessages) .values({ conversationId, authorId: null, botId: (await getVpnBotId(organizationId)) ?? null, message: text.trim(), replyToId: null, mentionedUserIds: null, attachments: attachments?.length ? attachments : null, botButtons: buttons?.length ? buttons : null, }) .returning(); const enriched = await enrichMessage(msg.id); const members = await getMembersForSSE(conversationId, organizationId); for (const { userId: memberId, orgId: memberOrgId } of members) { eventBus.publishEvent({ type: "conv_message_created", data: { conversationId, message: enriched }, organizationId: memberOrgId, userId: memberId, }); } 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 }]), ); notificationService.notifyMessengerMessage({ conversationId, authorId: -1, authorName: "ВПН бот", chatName: "ВПН бот", chatType: "bot_direct", messageText: text.trim(), mentionedUserIds: [], memberOrgMap, }).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; } // Simple in-memory state for the platform-selection step. // Key = conversationId. TTL cleanup runs every 5 minutes. const pendingPlatform = new Map(); function cleanPending() { const cutoff = Date.now() - 10 * 60 * 1000; for (const [key, value] of pendingPlatform.entries()) { if (value.createdAt < cutoff) pendingPlatform.delete(key); } } setInterval(cleanPending, 5 * 60 * 1000); 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, }; } 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 = pendingPlatform.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 }); return; } const roomUrl = extractRoomUrl(text); if (!roomUrl) { await sendVpnBotMessage(conversationId, INSTRUCTIONS_TEXT, organizationId); return; } // Start platform-selection dialog with buttons pendingPlatform.set(conversationId, { roomUrl, createdAt: Date.now() }); await sendVpnBotMessage(conversationId, PLATFORM_PROMPT, organizationId, undefined, PLATFORM_BUTTONS); } async function processPlatformSelection({ conversationId, user, organizationId, roomUrl, platform, }: { conversationId: number; user: User; organizationId: number; roomUrl: string; platform: "android" | "ios"; }): Promise { try { const userName = formatUserName(user); const result = await getOrCreateVpnSubscription(organizationId, user.id, userName, roomUrl); 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); } } 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 = pendingPlatform.get(conversationId); if (!pending) { await sendVpnBotMessage( conversationId, "Сессия выбора платформы истекла. Пришли ссылку на встречу заново.", organizationId, ); return; } const platform = callbackData === "platform:android" ? "android" : "ios"; pendingPlatform.delete(conversationId); await processPlatformSelection({ conversationId, user, organizationId, roomUrl: pending.roomUrl, platform }); return; } await sendVpnBotMessage( conversationId, `Неизвестное действие: ${callbackData}`, organizationId, ); }