- INSTRUCTIONS_TEXT: добавлено требование выйти из встречи, а не завершать её - vpn.db.ts: findAllActiveVpnTasks для поиска активных VPN-задач - vpn.service.ts: checkVpnRoomsValidity — периодическая проверка комнат, concurrency=5 - vpn-bot.service.ts: findVpnBotConversationId и notifyVpnRoomExpired - server/index.ts: worker каждые 6 часов, уведомление пользователю о протухшей ссылке
363 lines
13 KiB
TypeScript
363 lines
13 KiB
TypeScript
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,
|
||
);
|
||
}
|