Files
iistwin/server/vpn/vpn-bot.service.ts
Ильяс Султанов 6bb89b4cf8 feat(chat): typing indicator для ботов, кнопки ботов в мессенджере, VPN-бот с кнопками
- Добавлены SSE-события conv_typing_started/stopped и их обработка в Chat.tsx
- AI-бот публикует typing events перед/после генерации ответа
- Боты могут отправлять сообщения с кнопками через /api/bot/conversations/:id/messages
- Добавлен callback /api/messenger/messages/:id/callback для нажатий кнопок
- VPN-бот использует кнопки Android/iOS вместо текстового prompt
- Оптимистичное обновление unread count, ускоренный reportActiveChat, polling fallback,
  логирование SSE, push suppression для bot_direct
2026-07-17 11:25:25 +03:00

284 lines
9.9 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 = `Ссылка принята. Выбери платформу, и я пришлю подходящий файл приложения 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<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,
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<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 (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<void> {
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<void> {
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,
);
}