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
This commit is contained in:
2026-07-17 11:25:25 +03:00
parent 586a591773
commit 6bb89b4cf8
16 changed files with 492 additions and 60 deletions

View File

@@ -28,7 +28,12 @@ const INSTRUCTIONS_TEXT = `Привет! Для получения корпор
Можно прислать и сопроводительный текст — я сам найду ссылку.`;
const PLATFORM_PROMPT = `Ссылка принята. Теперь скажи, какой у тебя телефон:\n\n• Android\n• iPhone (iOS)\n\nЯ пришлю подходящий файл приложения olcbox.`;
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-сертификат и т.п.).`;
@@ -39,11 +44,14 @@ function formatUserName(user: User): string {
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)
@@ -55,6 +63,7 @@ export async function sendVpnBotMessage(
replyToId: null,
mentionedUserIds: null,
attachments: attachments?.length ? attachments : null,
botButtons: buttons?.length ? buttons : null,
})
.returning();
@@ -166,47 +175,16 @@ export async function handleVpnBotDirectMessage({
return;
}
// Continue platform-selection dialog
// 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);
await sendVpnBotMessage(conversationId, PLATFORM_PROMPT, organizationId, undefined, PLATFORM_BUTTONS);
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);
}
await processPlatformSelection({ conversationId, user, organizationId, roomUrl: pending.roomUrl, platform });
return;
}
@@ -216,7 +194,90 @@ export async function handleVpnBotDirectMessage({
return;
}
// Start platform-selection dialog
// Start platform-selection dialog with buttons
pendingPlatform.set(conversationId, { roomUrl, createdAt: Date.now() });
await sendVpnBotMessage(conversationId, PLATFORM_PROMPT, organizationId);
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,
);
}