Files
iistwin/server/vpn/vpn-bot.service.ts
Ильяс Султанов 586a591773 fix(vpn): восстановление VPN-модуля после затирания chat/push fix'ом
- Добавлена ветка bot.type === 'vpn' в messenger.messages.routes.ts
- Добавлен полный модуль server/vpn/ (routes, service, db, bot, config)
- Подключены VPN-маршруты в server/routes/index.ts
- Добавлена раздача /apps статики и COPY в Dockerfile
- apps/ пока содержит .gitkeep и README; APK/IPA нужно добавить отдельно
2026-07-17 09:32:43 +03:00

223 lines
8.1 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, 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 = `Ссылка принята. Теперь скажи, какой у тебя телефон:\n\n• Android\n• iPhone (iOS)\n\nЯ пришлю подходящий файл приложения olcbox.`;
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 };
export async function sendVpnBotMessage(
conversationId: number,
text: string,
organizationId: number,
attachments?: Attachment[],
): Promise<void> {
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,
})
.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<number | null> {
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<number, { roomUrl: string; createdAt: number }>();
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<void> {
const text = message.trim();
const lower = text.toLowerCase();
if (lower === "статус" || lower === "status") {
await sendVpnBotMessage(
conversationId,
"Статус подписки пока не реализован. Пришлите ссылку на встречу для получения подписки.",
organizationId,
);
return;
}
// Continue platform-selection dialog
const pending = pendingPlatform.get(conversationId);
if (pending) {
const platform = detectPlatform(text);
if (!platform) {
await sendVpnBotMessage(conversationId, PLATFORM_PROMPT, organizationId);
return;
}
pendingPlatform.delete(conversationId);
try {
const userName = formatUserName(user);
const result = await getOrCreateVpnSubscription(organizationId, user.id, userName, pending.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 message error:", err);
const userMessage = err instanceof Error ? err.message : "Произошла ошибка. Попробуйте позже.";
await sendVpnBotMessage(conversationId, userMessage, organizationId);
}
return;
}
const roomUrl = extractRoomUrl(text);
if (!roomUrl) {
await sendVpnBotMessage(conversationId, INSTRUCTIONS_TEXT, organizationId);
return;
}
// Start platform-selection dialog
pendingPlatform.set(conversationId, { roomUrl, createdAt: Date.now() });
await sendVpnBotMessage(conversationId, PLATFORM_PROMPT, organizationId);
}