Files
iistwin/server/vpn/vpn-bot.service.ts
Ильяс Султанов aea0e9c3fe feat(vpn): монитор актуальности Яндекс Телемост + инструкция про выход из встречи
- INSTRUCTIONS_TEXT: добавлено требование выйти из встречи, а не завершать её
- vpn.db.ts: findAllActiveVpnTasks для поиска активных VPN-задач
- vpn.service.ts: checkVpnRoomsValidity — периодическая проверка комнат, concurrency=5
- vpn-bot.service.ts: findVpnBotConversationId и notifyVpnRoomExpired
- server/index.ts: worker каждые 6 часов, уведомление пользователю о протухшей ссылке
2026-07-17 13:08:24 +03:00

363 lines
13 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, 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<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,
);
}
// ── Room expiry notifications ─────────────────────────────────────────────────
async function findVpnBotConversationId(
organizationId: number,
userId: number,
): Promise<number | null> {
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<void> {
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,
);
}