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:
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user