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 нужно добавить отдельно
This commit is contained in:
@@ -30,6 +30,9 @@ COPY --from=builder /app/server/scripts ./server/scripts
|
|||||||
RUN chmod +x docker-entrypoint.sh
|
RUN chmod +x docker-entrypoint.sh
|
||||||
RUN mkdir -p /app/data
|
RUN mkdir -p /app/data
|
||||||
|
|
||||||
|
# VPN client apps (olcbox APK/IPA)
|
||||||
|
COPY --from=builder /app/apps /app/apps
|
||||||
|
|
||||||
# Python + python-docx для обработки DOCX-шаблонов
|
# Python + python-docx для обработки DOCX-шаблонов
|
||||||
RUN apk add --no-cache python3 py3-pip py3-lxml \
|
RUN apk add --no-cache python3 py3-pip py3-lxml \
|
||||||
&& pip3 install --break-system-packages python-docx \
|
&& pip3 install --break-system-packages python-docx \
|
||||||
|
|||||||
0
apps/.gitkeep
Normal file
0
apps/.gitkeep
Normal file
10
apps/README.md
Normal file
10
apps/README.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# VPN client apps
|
||||||
|
|
||||||
|
Эта папка должна содержать файлы приложений olcbox, которые раздаёт VPN-бот:
|
||||||
|
|
||||||
|
- `Olcbox-1.0.112-android-release.apk`
|
||||||
|
- `Olcbox-1.0.112-ios-unsigned.ipa`
|
||||||
|
|
||||||
|
Файлы копируются в образ через `COPY --from=builder /app/apps /app/apps` в `Dockerfile`.
|
||||||
|
|
||||||
|
Без этих файлов сборка пройдёт, но бот не сможет прикрепить APK/IPA к сообщениям.
|
||||||
@@ -986,6 +986,9 @@ async function runStartupDataPatches() {
|
|||||||
|
|
||||||
const server = await registerRoutes(app);
|
const server = await registerRoutes(app);
|
||||||
|
|
||||||
|
// Static files for VPN client apps (olcbox APK/IPA)
|
||||||
|
app.use('/apps', express.static('/app/apps'));
|
||||||
|
|
||||||
// Seed application roles and permissions
|
// Seed application roles and permissions
|
||||||
try {
|
try {
|
||||||
await storage.seedAppRolesAndPermissions();
|
await storage.seedAppRolesAndPermissions();
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import organizationSkinsRouter from "./organization-skins.routes";
|
|||||||
import { registerSyncRoutes } from "./sync.routes";
|
import { registerSyncRoutes } from "./sync.routes";
|
||||||
import { registerMedScheduleRoutes } from "../medschedule/routes";
|
import { registerMedScheduleRoutes } from "../medschedule/routes";
|
||||||
import { registerMedScheduleBotRoutes } from "../medschedule/bot.routes";
|
import { registerMedScheduleBotRoutes } from "../medschedule/bot.routes";
|
||||||
|
import { registerVpnRoutes } from "../vpn";
|
||||||
import { FIELD_TYPE_DEFINITIONS } from "../../shared/field-types";
|
import { FIELD_TYPE_DEFINITIONS } from "../../shared/field-types";
|
||||||
export { publishNotificationSSE };
|
export { publishNotificationSSE };
|
||||||
|
|
||||||
@@ -117,6 +118,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
registerSyncRoutes(app);
|
registerSyncRoutes(app);
|
||||||
registerMedScheduleRoutes(app);
|
registerMedScheduleRoutes(app);
|
||||||
registerMedScheduleBotRoutes(app);
|
registerMedScheduleBotRoutes(app);
|
||||||
|
registerVpnRoutes(app);
|
||||||
|
|
||||||
const httpServer = createServer(app);
|
const httpServer = createServer(app);
|
||||||
return httpServer;
|
return httpServer;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { formatUserName } from "../utils/formatUserName";
|
|||||||
import { notificationService } from "../services/notification.service";
|
import { notificationService } from "../services/notification.service";
|
||||||
import { handleAiBotDirectMessage } from "../services/ai-bot.service";
|
import { handleAiBotDirectMessage } from "../services/ai-bot.service";
|
||||||
import { checkBotAccess } from "../services/ai-bot.service";
|
import { checkBotAccess } from "../services/ai-bot.service";
|
||||||
|
import { handleVpnBotDirectMessage } from "../vpn/vpn-bot.service";
|
||||||
|
|
||||||
export function registerMessengerMessageRoutes(router: Router): void {
|
export function registerMessengerMessageRoutes(router: Router): void {
|
||||||
// GET /api/messenger/conversations/:id/messages
|
// GET /api/messenger/conversations/:id/messages
|
||||||
@@ -249,71 +250,111 @@ export function registerMessengerMessageRoutes(router: Router): void {
|
|||||||
.where(eq(bots.id, botId));
|
.where(eq(bots.id, botId));
|
||||||
|
|
||||||
if (!bot || !bot.isActive) return;
|
if (!bot || !bot.isActive) return;
|
||||||
if (bot.type !== 'ai_assistant') return;
|
|
||||||
const typedBot = bot as Bot;
|
if (bot.type === 'ai_assistant') {
|
||||||
if (!checkBotAccess(typedBot, req.user!)) {
|
const typedBot = bot as Bot;
|
||||||
const [denyMsg] = await db
|
if (!checkBotAccess(typedBot, req.user!)) {
|
||||||
.insert(conversationMessages)
|
const [denyMsg] = await db
|
||||||
.values({
|
.insert(conversationMessages)
|
||||||
|
.values({
|
||||||
|
conversationId: convId,
|
||||||
|
authorId: null,
|
||||||
|
botId: bot.id,
|
||||||
|
message: `⛔ У вас нет доступа к боту ${bot.name}`,
|
||||||
|
replyToId: null,
|
||||||
|
mentionedUserIds: null,
|
||||||
|
attachments: null,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
const enrichedDeny = await enrichMessage(denyMsg.id);
|
||||||
|
for (const { userId: memberId, orgId: memberOrgId } of members) {
|
||||||
|
eventBus.publishEvent({ type: "conv_message_created", data: { conversationId: convId, message: enrichedDeny }, organizationId: memberOrgId, userId: memberId });
|
||||||
|
}
|
||||||
|
notificationService.notifyMessengerMessage({
|
||||||
conversationId: convId,
|
conversationId: convId,
|
||||||
authorId: null,
|
authorId: -1,
|
||||||
botId: bot.id,
|
authorName: bot.name,
|
||||||
message: `⛔ У вас нет доступа к боту ${bot.name}`,
|
chatName: bot.name,
|
||||||
replyToId: null,
|
chatType: 'bot_direct',
|
||||||
mentionedUserIds: null,
|
messageText: `⛔ У вас нет доступа к боту ${bot.name}`,
|
||||||
attachments: null,
|
mentionedUserIds: [],
|
||||||
})
|
memberOrgMap,
|
||||||
.returning();
|
}).catch(err => console.error('[Bot Deny Notify] Error:', err));
|
||||||
const enrichedDeny = await enrichMessage(denyMsg.id);
|
return;
|
||||||
for (const { userId: memberId, orgId: memberOrgId } of members) {
|
|
||||||
eventBus.publishEvent({ type: "conv_message_created", data: { conversationId: convId, message: enrichedDeny }, organizationId: memberOrgId, userId: memberId });
|
|
||||||
}
|
}
|
||||||
notificationService.notifyMessengerMessage({
|
|
||||||
|
await handleAiBotDirectMessage({
|
||||||
|
bot: typedBot,
|
||||||
conversationId: convId,
|
conversationId: convId,
|
||||||
authorId: -1,
|
message: message.trim(),
|
||||||
authorName: bot.name,
|
user: req.user!,
|
||||||
chatName: bot.name,
|
organizationId: orgId,
|
||||||
chatType: 'bot_direct',
|
});
|
||||||
messageText: `⛔ У вас нет доступа к боту ${bot.name}`,
|
|
||||||
mentionedUserIds: [],
|
|
||||||
memberOrgMap,
|
|
||||||
}).catch(err => console.error('[Bot Deny Notify] Error:', err));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await handleAiBotDirectMessage({
|
const latestBotMsg = await db
|
||||||
bot: typedBot,
|
.select({ id: conversationMessages.id })
|
||||||
conversationId: convId,
|
.from(conversationMessages)
|
||||||
message: message.trim(),
|
.where(and(
|
||||||
user: req.user!,
|
eq(conversationMessages.conversationId, convId),
|
||||||
organizationId: orgId,
|
eq(conversationMessages.botId, botId),
|
||||||
});
|
))
|
||||||
|
.orderBy(desc(conversationMessages.id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
const latestBotMsg = await db
|
if (latestBotMsg[0]) {
|
||||||
.select({ id: conversationMessages.id })
|
const enrichedBot = await enrichMessage(latestBotMsg[0].id);
|
||||||
.from(conversationMessages)
|
for (const { userId: memberId, orgId: memberOrgId } of members) {
|
||||||
.where(and(
|
eventBus.publishEvent({ type: "conv_message_created", data: { conversationId: convId, message: enrichedBot }, organizationId: memberOrgId, userId: memberId });
|
||||||
eq(conversationMessages.conversationId, convId),
|
}
|
||||||
eq(conversationMessages.botId, botId),
|
notificationService.notifyMessengerMessage({
|
||||||
))
|
conversationId: convId,
|
||||||
.orderBy(desc(conversationMessages.id))
|
authorId: -1,
|
||||||
.limit(1);
|
authorName: bot.name,
|
||||||
|
chatName: bot.name,
|
||||||
if (latestBotMsg[0]) {
|
chatType: 'bot_direct',
|
||||||
const enrichedBot = await enrichMessage(latestBotMsg[0].id);
|
messageText: (enrichedBot as any).message ?? '',
|
||||||
for (const { userId: memberId, orgId: memberOrgId } of members) {
|
mentionedUserIds: [],
|
||||||
eventBus.publishEvent({ type: "conv_message_created", data: { conversationId: convId, message: enrichedBot }, organizationId: memberOrgId, userId: memberId });
|
memberOrgMap,
|
||||||
|
}).catch(err => console.error('[Bot Reply Notify] Error:', err));
|
||||||
}
|
}
|
||||||
notificationService.notifyMessengerMessage({
|
} else if (bot.type === 'vpn') {
|
||||||
|
const typedBot = bot as Bot;
|
||||||
|
if (!checkBotAccess(typedBot, req.user!)) {
|
||||||
|
const [denyMsg] = await db
|
||||||
|
.insert(conversationMessages)
|
||||||
|
.values({
|
||||||
|
conversationId: convId,
|
||||||
|
authorId: null,
|
||||||
|
botId: bot.id,
|
||||||
|
message: `⛔ У вас нет доступа к боту ${bot.name}`,
|
||||||
|
replyToId: null,
|
||||||
|
mentionedUserIds: null,
|
||||||
|
attachments: null,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
const enrichedDeny = await enrichMessage(denyMsg.id);
|
||||||
|
for (const { userId: memberId, orgId: memberOrgId } of members) {
|
||||||
|
eventBus.publishEvent({ type: "conv_message_created", data: { conversationId: convId, message: enrichedDeny }, organizationId: memberOrgId, userId: memberId });
|
||||||
|
}
|
||||||
|
notificationService.notifyMessengerMessage({
|
||||||
|
conversationId: convId,
|
||||||
|
authorId: -1,
|
||||||
|
authorName: bot.name,
|
||||||
|
chatName: bot.name,
|
||||||
|
chatType: 'bot_direct',
|
||||||
|
messageText: `⛔ У вас нет доступа к боту ${bot.name}`,
|
||||||
|
mentionedUserIds: [],
|
||||||
|
memberOrgMap,
|
||||||
|
}).catch(err => console.error('[VPN Bot Deny Notify] Error:', err));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await handleVpnBotDirectMessage({
|
||||||
|
bot: typedBot,
|
||||||
conversationId: convId,
|
conversationId: convId,
|
||||||
authorId: -1,
|
message: message.trim(),
|
||||||
authorName: bot.name,
|
user: req.user!,
|
||||||
chatName: bot.name,
|
organizationId: orgId,
|
||||||
chatType: 'bot_direct',
|
});
|
||||||
messageText: (enrichedBot as any).message ?? '',
|
|
||||||
mentionedUserIds: [],
|
|
||||||
memberOrgMap,
|
|
||||||
}).catch(err => console.error('[Bot Reply Notify] Error:', err));
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
1
server/vpn/index.ts
Normal file
1
server/vpn/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { registerVpnRoutes } from './vpn.routes';
|
||||||
222
server/vpn/vpn-bot.service.ts
Normal file
222
server/vpn/vpn-bot.service.ts
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
19
server/vpn/vpn.config.ts
Normal file
19
server/vpn/vpn.config.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
export const VPN_FORM_ID = 19;
|
||||||
|
export const VPN_BOT_LOGIN = 'vpn_bot';
|
||||||
|
export const VPN_BOT_NAME = 'ВПН бот';
|
||||||
|
|
||||||
|
export const SUBSCRIPTION_BASE_URL =
|
||||||
|
process.env.VPN_SUBSCRIPTION_BASE_URL ||
|
||||||
|
(process.env.APP_URL ? `${process.env.APP_URL}/api/vpn/sub` : 'https://iistwin.ru/api/vpn/sub');
|
||||||
|
|
||||||
|
export const VPN_TELEMOST_API_BASE =
|
||||||
|
'https://cloud-api.yandex.ru/telemost_front/v2/telemost';
|
||||||
|
|
||||||
|
export const DEFAULT_TRAFFIC_LIMIT_GB = 100;
|
||||||
|
export const DEFAULT_SUBSCRIPTION_DAYS = 30;
|
||||||
|
export const SUBSCRIPTION_UPDATE_INTERVAL_HOURS = 1;
|
||||||
|
|
||||||
|
export const OLC_RTC_PROVIDER = 'telemost';
|
||||||
|
export const OLC_RTC_TRANSPORT = 'vp8channel';
|
||||||
|
export const OLC_RTC_VP8_FPS = 30;
|
||||||
|
export const OLC_RTC_VP8_BATCH = 64;
|
||||||
286
server/vpn/vpn.db.ts
Normal file
286
server/vpn/vpn.db.ts
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
import { db } from '../db';
|
||||||
|
import {
|
||||||
|
formFields,
|
||||||
|
formStatuses,
|
||||||
|
tasks,
|
||||||
|
taskFieldValues,
|
||||||
|
type Task,
|
||||||
|
type FormField,
|
||||||
|
type FormStatus,
|
||||||
|
} from '@shared/schema';
|
||||||
|
import { eq, and, sql } from 'drizzle-orm';
|
||||||
|
import { storage } from '../storage';
|
||||||
|
import { VPN_FORM_ID } from './vpn.config';
|
||||||
|
|
||||||
|
export interface VpnFormCache {
|
||||||
|
fields: FormField[];
|
||||||
|
statuses: FormStatus[];
|
||||||
|
fieldByCode: Map<string, FormField>;
|
||||||
|
statusByName: Map<string, FormStatus>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheByOrg = new Map<number, VpnFormCache>();
|
||||||
|
|
||||||
|
export async function getVpnFormCache(organizationId: number): Promise<VpnFormCache> {
|
||||||
|
if (cacheByOrg.has(organizationId)) {
|
||||||
|
return cacheByOrg.get(organizationId)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [fields, statuses] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(formFields)
|
||||||
|
.where(eq(formFields.formId, VPN_FORM_ID))
|
||||||
|
.then(rows => rows as FormField[]),
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(formStatuses)
|
||||||
|
.where(eq(formStatuses.formId, VPN_FORM_ID))
|
||||||
|
.then(rows => rows as FormStatus[]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const cache: VpnFormCache = {
|
||||||
|
fields,
|
||||||
|
statuses,
|
||||||
|
fieldByCode: new Map(fields.map(f => [f.code, f])),
|
||||||
|
statusByName: new Map(statuses.map(s => [s.name, s])),
|
||||||
|
};
|
||||||
|
cacheByOrg.set(organizationId, cache);
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFieldId(cache: VpnFormCache, code: string): number {
|
||||||
|
const f = cache.fieldByCode.get(code);
|
||||||
|
if (!f) throw new Error(`VPN field not found: ${code}`);
|
||||||
|
return f.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStatusId(cache: VpnFormCache, name: string): number {
|
||||||
|
const s = cache.statusByName.get(name);
|
||||||
|
if (!s) throw new Error(`VPN status not found: ${name}`);
|
||||||
|
return s.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearVpnFormCache(organizationId: number): void {
|
||||||
|
cacheByOrg.delete(organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VpnTaskData {
|
||||||
|
task: Task;
|
||||||
|
values: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findVpnTaskByToken(
|
||||||
|
token: string,
|
||||||
|
organizationId: number,
|
||||||
|
): Promise<VpnTaskData | null> {
|
||||||
|
const cache = await getVpnFormCache(organizationId);
|
||||||
|
const fieldId = getFieldId(cache, 'subscription_token');
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({ task: tasks, value: taskFieldValues.value })
|
||||||
|
.from(taskFieldValues)
|
||||||
|
.innerJoin(tasks, eq(taskFieldValues.taskId, tasks.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(taskFieldValues.fieldId, fieldId),
|
||||||
|
eq(taskFieldValues.formId, VPN_FORM_ID),
|
||||||
|
sql`${taskFieldValues.value} = to_jsonb(${token}::text)`,
|
||||||
|
eq(tasks.organizationId, organizationId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!rows.length) return null;
|
||||||
|
return loadVpnTaskValues(rows[0].task.id, organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findVpnTaskByRoomUrl(
|
||||||
|
roomUrl: string,
|
||||||
|
organizationId: number,
|
||||||
|
): Promise<VpnTaskData | null> {
|
||||||
|
const cache = await getVpnFormCache(organizationId);
|
||||||
|
const fieldId = getFieldId(cache, 'room_url');
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({ task: tasks })
|
||||||
|
.from(taskFieldValues)
|
||||||
|
.innerJoin(tasks, eq(taskFieldValues.taskId, tasks.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(taskFieldValues.fieldId, fieldId),
|
||||||
|
eq(taskFieldValues.formId, VPN_FORM_ID),
|
||||||
|
sql`${taskFieldValues.value} = to_jsonb(${roomUrl}::text)`,
|
||||||
|
eq(tasks.organizationId, organizationId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!rows.length) return null;
|
||||||
|
return loadVpnTaskValues(rows[0].task.id, organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findVpnTaskByRoomAndDevice(
|
||||||
|
roomUrl: string,
|
||||||
|
deviceId: string,
|
||||||
|
organizationId: number,
|
||||||
|
): Promise<VpnTaskData | null> {
|
||||||
|
const cache = await getVpnFormCache(organizationId);
|
||||||
|
const roomFieldId = getFieldId(cache, 'room_url');
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({ task: tasks })
|
||||||
|
.from(taskFieldValues)
|
||||||
|
.innerJoin(tasks, eq(taskFieldValues.taskId, tasks.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(taskFieldValues.fieldId, roomFieldId),
|
||||||
|
eq(taskFieldValues.formId, VPN_FORM_ID),
|
||||||
|
sql`${taskFieldValues.value} = to_jsonb(${roomUrl}::text)`,
|
||||||
|
eq(tasks.organizationId, organizationId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!rows.length) return null;
|
||||||
|
const data = await loadVpnTaskValues(rows[0].task.id, organizationId);
|
||||||
|
if (data.values.hwid !== deviceId) return null;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findVpnTaskByRoomAndSession(
|
||||||
|
roomUrl: string,
|
||||||
|
sessionId: string,
|
||||||
|
organizationId: number,
|
||||||
|
): Promise<VpnTaskData | null> {
|
||||||
|
const cache = await getVpnFormCache(organizationId);
|
||||||
|
const roomFieldId = getFieldId(cache, 'room_url');
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({ task: tasks })
|
||||||
|
.from(taskFieldValues)
|
||||||
|
.innerJoin(tasks, eq(taskFieldValues.taskId, tasks.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(taskFieldValues.fieldId, roomFieldId),
|
||||||
|
eq(taskFieldValues.formId, VPN_FORM_ID),
|
||||||
|
sql`${taskFieldValues.value} = to_jsonb(${roomUrl}::text)`,
|
||||||
|
eq(tasks.organizationId, organizationId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!rows.length) return null;
|
||||||
|
const data = await loadVpnTaskValues(rows[0].task.id, organizationId);
|
||||||
|
if (data.values.active_session_id !== sessionId) return null;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadVpnTaskValues(
|
||||||
|
taskId: number,
|
||||||
|
organizationId: number,
|
||||||
|
): Promise<VpnTaskData> {
|
||||||
|
const cache = await getVpnFormCache(organizationId);
|
||||||
|
const [task, fvs] = await Promise.all([
|
||||||
|
storage.getTask(taskId, organizationId),
|
||||||
|
storage.getTaskFieldValues(taskId, organizationId),
|
||||||
|
]);
|
||||||
|
if (!task) throw new Error(`VPN task not found: ${taskId}`);
|
||||||
|
|
||||||
|
const values: Record<string, unknown> = {};
|
||||||
|
for (const fv of fvs) {
|
||||||
|
const field = cache.fields.find(f => f.id === fv.fieldId);
|
||||||
|
if (field) {
|
||||||
|
values[field.code] = fv.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { task, values };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setVpnTaskField(
|
||||||
|
taskId: number,
|
||||||
|
fieldCode: string,
|
||||||
|
value: unknown,
|
||||||
|
organizationId: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const cache = await getVpnFormCache(organizationId);
|
||||||
|
const fieldId = getFieldId(cache, fieldCode);
|
||||||
|
|
||||||
|
const existing = await db
|
||||||
|
.select({ id: taskFieldValues.id })
|
||||||
|
.from(taskFieldValues)
|
||||||
|
.where(and(eq(taskFieldValues.taskId, taskId), eq(taskFieldValues.fieldId, fieldId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing.length) {
|
||||||
|
await storage.updateTaskFieldValue(taskId, fieldId, organizationId, { value: value as any });
|
||||||
|
} else {
|
||||||
|
await storage.createTaskFieldValue({
|
||||||
|
taskId,
|
||||||
|
fieldId,
|
||||||
|
formId: VPN_FORM_ID,
|
||||||
|
value: value as any,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setVpnTaskStatus(
|
||||||
|
taskId: number,
|
||||||
|
statusName: string,
|
||||||
|
organizationId: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const cache = await getVpnFormCache(organizationId);
|
||||||
|
const statusId = getStatusId(cache, statusName);
|
||||||
|
const status = cache.statusByName.get(statusName)!;
|
||||||
|
await storage.updateTask(taskId, organizationId, {
|
||||||
|
currentStatusId: statusId,
|
||||||
|
isCompleted: status.isFinal,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createVpnTask(
|
||||||
|
input: {
|
||||||
|
organizationId: number;
|
||||||
|
createdBy: number;
|
||||||
|
title: string;
|
||||||
|
statusName: string;
|
||||||
|
fields: Record<string, unknown>;
|
||||||
|
},
|
||||||
|
): Promise<Task> {
|
||||||
|
const cache = await getVpnFormCache(input.organizationId);
|
||||||
|
const statusId = getStatusId(cache, input.statusName);
|
||||||
|
|
||||||
|
const task = await storage.createTask({
|
||||||
|
organizationId: input.organizationId,
|
||||||
|
formId: VPN_FORM_ID,
|
||||||
|
title: input.title,
|
||||||
|
currentStatusId: statusId,
|
||||||
|
createdBy: input.createdBy,
|
||||||
|
completedAt: null,
|
||||||
|
dueDate: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [code, value] of Object.entries(input.fields)) {
|
||||||
|
if (value === undefined || value === null) continue;
|
||||||
|
const fieldId = getFieldId(cache, code);
|
||||||
|
await storage.createTaskFieldValue({
|
||||||
|
taskId: task.id,
|
||||||
|
fieldId,
|
||||||
|
formId: VPN_FORM_ID,
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateVpnTaskFieldMap(
|
||||||
|
taskId: number,
|
||||||
|
organizationId: number,
|
||||||
|
fields: Record<string, unknown>,
|
||||||
|
): Promise<void> {
|
||||||
|
for (const [code, value] of Object.entries(fields)) {
|
||||||
|
if (value === undefined) continue;
|
||||||
|
await setVpnTaskField(taskId, code, value, organizationId);
|
||||||
|
}
|
||||||
|
}
|
||||||
87
server/vpn/vpn.routes.ts
Normal file
87
server/vpn/vpn.routes.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import {
|
||||||
|
fetchSubscription,
|
||||||
|
authorizeSession,
|
||||||
|
closeSession,
|
||||||
|
recordTraffic,
|
||||||
|
VpnError,
|
||||||
|
} from "./vpn.service";
|
||||||
|
import { SUBSCRIPTION_UPDATE_INTERVAL_HOURS } from "./vpn.config";
|
||||||
|
|
||||||
|
function handleVpnError(res: Response, err: unknown): void {
|
||||||
|
if (err instanceof VpnError) {
|
||||||
|
res.status(403).type("text/plain").send(err.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error("[VPN API] error:", err);
|
||||||
|
res.status(500).type("text/plain").send("Internal error");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerVpnRoutes(router: Router): void {
|
||||||
|
// Subscription endpoint used by olcbox clients
|
||||||
|
router.get("/api/vpn/sub/:token", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const token = req.params.token;
|
||||||
|
const hwid = req.headers["x-hwid"] as string | undefined;
|
||||||
|
const uri = await fetchSubscription(token, hwid);
|
||||||
|
res.setHeader("profile-update-interval", String(SUBSCRIPTION_UPDATE_INTERVAL_HOURS));
|
||||||
|
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||||
|
res.send(uri);
|
||||||
|
} catch (err) {
|
||||||
|
handleVpnError(res, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// olrtc srv AuthHook
|
||||||
|
router.post("/api/vpn/olrtc/auth", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { device_id, room_url } = req.body || {};
|
||||||
|
if (!device_id || !room_url) {
|
||||||
|
res.status(400).json({ success: false, error: "missing device_id or room_url" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await authorizeSession(room_url, device_id);
|
||||||
|
res.json({ success: true, session_id: result.sessionId });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof VpnError) {
|
||||||
|
res.status(403).json({ success: false, error: err.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error("[VPN Auth] error:", err);
|
||||||
|
res.status(500).json({ success: false, error: "internal error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// olrtc srv OnSessionClose
|
||||||
|
router.post("/api/vpn/olrtc/close", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { session_id, room_url } = req.body || {};
|
||||||
|
if (!session_id || !room_url) {
|
||||||
|
res.status(400).json({ success: false, error: "missing fields" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await closeSession(room_url, session_id);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[VPN Close] error:", err);
|
||||||
|
res.status(500).json({ success: false, error: "internal error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// olrtc srv OnTraffic
|
||||||
|
router.post("/api/vpn/olrtc/traffic", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { session_id, room_url, bytes_in, bytes_out } = req.body || {};
|
||||||
|
if (!session_id || !room_url || typeof bytes_in !== "number" || typeof bytes_out !== "number") {
|
||||||
|
res.status(400).json({ success: false, error: "missing fields" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await recordTraffic(room_url, session_id, bytes_in, bytes_out);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[VPN Traffic] error:", err);
|
||||||
|
res.status(500).json({ success: false, error: "internal error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
253
server/vpn/vpn.service.ts
Normal file
253
server/vpn/vpn.service.ts
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
import {
|
||||||
|
findVpnTaskByToken,
|
||||||
|
findVpnTaskByRoomUrl,
|
||||||
|
findVpnTaskByRoomAndDevice,
|
||||||
|
findVpnTaskByRoomAndSession,
|
||||||
|
loadVpnTaskValues,
|
||||||
|
setVpnTaskField,
|
||||||
|
setVpnTaskStatus,
|
||||||
|
updateVpnTaskFieldMap,
|
||||||
|
createVpnTask,
|
||||||
|
getVpnFormCache,
|
||||||
|
getFieldId,
|
||||||
|
type VpnFormCache,
|
||||||
|
} from './vpn.db';
|
||||||
|
import {
|
||||||
|
SUBSCRIPTION_BASE_URL,
|
||||||
|
DEFAULT_TRAFFIC_LIMIT_GB,
|
||||||
|
DEFAULT_SUBSCRIPTION_DAYS,
|
||||||
|
SUBSCRIPTION_UPDATE_INTERVAL_HOURS,
|
||||||
|
OLC_RTC_PROVIDER,
|
||||||
|
OLC_RTC_TRANSPORT,
|
||||||
|
OLC_RTC_VP8_FPS,
|
||||||
|
OLC_RTC_VP8_BATCH,
|
||||||
|
VPN_TELEMOST_API_BASE,
|
||||||
|
} from './vpn.config';
|
||||||
|
|
||||||
|
export class VpnError extends Error {
|
||||||
|
constructor(message: string, public readonly code: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'VpnError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateToken(): string {
|
||||||
|
return crypto.randomBytes(24).toString('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateCryptoKey(): string {
|
||||||
|
return crypto.randomBytes(32).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSubscriptionUrl(token: string): string {
|
||||||
|
return `${SUBSCRIPTION_BASE_URL}/${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildOlcRtcUri(roomId: string, cryptoKey: string, employeeName: string): string {
|
||||||
|
const name = (employeeName || 'Corp VPN').replace(/\s+/g, ' ').trim();
|
||||||
|
return `olcrtc://${OLC_RTC_PROVIDER}?${OLC_RTC_TRANSPORT}<vp8-fps=${OLC_RTC_VP8_FPS}&vp8-batch=${OLC_RTC_VP8_BATCH}>@${roomId}#${cryptoKey}$${name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractRoomUrl(text: string): string | null {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
|
||||||
|
// Full or partial link: https://telemost.yandex.ru/j/XXXXXX (with optional www, query, hash)
|
||||||
|
const linkMatch = trimmed.match(/(?:https?:\/\/)?(?:www\.)?telemost\.yandex\.ru\/j\/([a-zA-Z0-9_-]+)/i);
|
||||||
|
if (linkMatch) {
|
||||||
|
return `https://telemost.yandex.ru/j/${linkMatch[1]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Just the room ID
|
||||||
|
if (/^[a-zA-Z0-9_-]+$/.test(trimmed)) {
|
||||||
|
return `https://telemost.yandex.ru/j/${trimmed}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractRoomId(roomUrl: string): string {
|
||||||
|
const idx = roomUrl.lastIndexOf('/j/');
|
||||||
|
if (idx === -1) return roomUrl;
|
||||||
|
return roomUrl.slice(idx + 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validateTelemostRoom(roomUrl: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const encoded = encodeURIComponent(roomUrl);
|
||||||
|
const url = `${VPN_TELEMOST_API_BASE}/conferences/${encoded}/connection?next_gen_media_platform_allowed=true&display_name=VPN-Bot&waiting_room_supported=true`;
|
||||||
|
const resp = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:149.0) Gecko/20100101 Firefox/149.0',
|
||||||
|
Accept: '*/*',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Client-Instance-Id': crypto.randomUUID(),
|
||||||
|
'X-Telemost-Client-Version': '187.1.0',
|
||||||
|
'Idempotency-Key': crypto.randomUUID(),
|
||||||
|
Origin: 'https://telemost.yandex.ru',
|
||||||
|
Referer: 'https://telemost.yandex.ru/',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return resp.status === 200;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[VPN] Telemost validation error:', err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOrCreateVpnSubscription(
|
||||||
|
organizationId: number,
|
||||||
|
userId: number,
|
||||||
|
userName: string,
|
||||||
|
roomUrl: string,
|
||||||
|
): Promise<{ subscriptionUrl: string; isNew: boolean }> {
|
||||||
|
const existing = await findVpnTaskByRoomUrl(roomUrl, organizationId);
|
||||||
|
if (existing && existing.task.createdBy !== userId) {
|
||||||
|
throw new VpnError('Эта комната уже используется другим сотрудником', 'ROOM_IN_USE');
|
||||||
|
}
|
||||||
|
if (existing) {
|
||||||
|
const token = existing.values.subscription_token as string | undefined;
|
||||||
|
if (!token) throw new VpnError('Подписка повреждена, обратитесь к администратору', 'BROKEN');
|
||||||
|
return { subscriptionUrl: buildSubscriptionUrl(token), isNew: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const roomId = extractRoomId(roomUrl);
|
||||||
|
const isValid = await validateTelemostRoom(roomUrl);
|
||||||
|
if (!isValid) {
|
||||||
|
throw new VpnError(
|
||||||
|
'Не удалось найти встречу. Создайте новую в Яндекс Телемост и пришлите ссылку.',
|
||||||
|
'ROOM_NOT_FOUND',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = generateToken();
|
||||||
|
const cryptoKey = generateCryptoKey();
|
||||||
|
const expiresAt = new Date();
|
||||||
|
expiresAt.setDate(expiresAt.getDate() + DEFAULT_SUBSCRIPTION_DAYS);
|
||||||
|
|
||||||
|
await createVpnTask({
|
||||||
|
organizationId,
|
||||||
|
createdBy: userId,
|
||||||
|
title: `VPN ${userName || `#${userId}`} / ${roomId}`,
|
||||||
|
statusName: 'Активна',
|
||||||
|
fields: {
|
||||||
|
employee_name: userName || '',
|
||||||
|
room_id: roomId,
|
||||||
|
room_url: roomUrl,
|
||||||
|
crypto_key: cryptoKey,
|
||||||
|
subscription_token: token,
|
||||||
|
status: 'Активна',
|
||||||
|
expires_at: expiresAt.toISOString().slice(0, 10),
|
||||||
|
traffic_limit_gb: DEFAULT_TRAFFIC_LIMIT_GB,
|
||||||
|
traffic_used_gb: 0,
|
||||||
|
is_active: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { subscriptionUrl: buildSubscriptionUrl(token), isNew: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSubscription(token: string, hwid: string | undefined): Promise<string> {
|
||||||
|
// We don't know organizationId here; token is globally unique.
|
||||||
|
// Search across all orgs by token.
|
||||||
|
const orgIds = [1]; // TODO: support multi-org
|
||||||
|
for (const orgId of orgIds) {
|
||||||
|
const data = await findVpnTaskByToken(token, orgId);
|
||||||
|
if (!data) continue;
|
||||||
|
|
||||||
|
const { task, values } = data;
|
||||||
|
if (!values.is_active || values.status !== 'Активна') {
|
||||||
|
throw new VpnError('Подписка неактивна', 'INACTIVE');
|
||||||
|
}
|
||||||
|
if (values.expires_at && new Date(values.expires_at as string) < new Date()) {
|
||||||
|
await setVpnTaskStatus(task.id, 'Истекла', orgId);
|
||||||
|
throw new VpnError('Срок действия подписки истёк', 'EXPIRED');
|
||||||
|
}
|
||||||
|
const limit = Number(values.traffic_limit_gb || 0);
|
||||||
|
const used = Number(values.traffic_used_gb || 0);
|
||||||
|
if (limit > 0 && used >= limit) {
|
||||||
|
throw new VpnError('Превышен лимит трафика', 'TRAFFIC_LIMIT');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hwid) {
|
||||||
|
const boundHwid = values.hwid as string | undefined;
|
||||||
|
if (!boundHwid) {
|
||||||
|
await setVpnTaskField(task.id, 'hwid', hwid, orgId);
|
||||||
|
} else if (boundHwid !== hwid) {
|
||||||
|
throw new VpnError('Подписка привязана к другому устройству', 'HWID_MISMATCH');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const roomId = values.room_id as string;
|
||||||
|
const cryptoKey = values.crypto_key as string;
|
||||||
|
const employeeName = (values.employee_name as string) || 'Corp VPN';
|
||||||
|
const uri = buildOlcRtcUri(roomId, cryptoKey, employeeName);
|
||||||
|
return uri;
|
||||||
|
}
|
||||||
|
throw new VpnError('Подписка не найдена', 'NOT_FOUND');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function authorizeSession(
|
||||||
|
roomUrl: string,
|
||||||
|
deviceId: string,
|
||||||
|
): Promise<{ sessionId: string; organizationId: number; taskId: number }> {
|
||||||
|
const orgIds = [1]; // TODO: support multi-org
|
||||||
|
for (const orgId of orgIds) {
|
||||||
|
const data = await findVpnTaskByRoomAndDevice(roomUrl, deviceId, orgId);
|
||||||
|
if (!data) continue;
|
||||||
|
|
||||||
|
const { task, values } = data;
|
||||||
|
if (!values.is_active || values.status !== 'Активна') {
|
||||||
|
throw new VpnError('Подписка неактивна', 'INACTIVE');
|
||||||
|
}
|
||||||
|
if (values.expires_at && new Date(values.expires_at as string) < new Date()) {
|
||||||
|
throw new VpnError('Срок действия подписки истёк', 'EXPIRED');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = crypto.randomUUID();
|
||||||
|
await updateVpnTaskFieldMap(task.id, orgId, {
|
||||||
|
active_session_id: sessionId,
|
||||||
|
last_seen_at: new Date().toISOString(),
|
||||||
|
last_error: '',
|
||||||
|
});
|
||||||
|
return { sessionId, organizationId: orgId, taskId: task.id };
|
||||||
|
}
|
||||||
|
throw new VpnError('Устройство не авторизовано', 'UNAUTHORIZED');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeSession(
|
||||||
|
roomUrl: string,
|
||||||
|
sessionId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const orgIds = [1];
|
||||||
|
for (const orgId of orgIds) {
|
||||||
|
const data = await findVpnTaskByRoomAndSession(roomUrl, sessionId, orgId);
|
||||||
|
if (!data) continue;
|
||||||
|
const activeSession = data.values.active_session_id as string | undefined;
|
||||||
|
if (activeSession === sessionId) {
|
||||||
|
await setVpnTaskField(data.task.id, 'active_session_id', '', orgId);
|
||||||
|
await setVpnTaskField(data.task.id, 'last_seen_at', new Date().toISOString(), orgId);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recordTraffic(
|
||||||
|
roomUrl: string,
|
||||||
|
sessionId: string,
|
||||||
|
bytesIn: number,
|
||||||
|
bytesOut: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const orgIds = [1];
|
||||||
|
for (const orgId of orgIds) {
|
||||||
|
const data = await findVpnTaskByRoomAndSession(roomUrl, sessionId, orgId);
|
||||||
|
if (!data) continue;
|
||||||
|
const used = Number(data.values.traffic_used_gb || 0);
|
||||||
|
const added = (bytesIn + bytesOut) / (1024 * 1024 * 1024);
|
||||||
|
await updateVpnTaskFieldMap(data.task.id, orgId, {
|
||||||
|
traffic_used_gb: Math.round((used + added) * 1000) / 1000,
|
||||||
|
last_seen_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user