'use client'; import { useCallback, useEffect, useRef, useState } from 'react'; import { LayoutGrid, Loader2, Send, X } from 'lucide-react'; import { BotMessageKeyboard } from '@/components/chat/bot-message-keyboard'; import { BotChatMessageContextMenu } from '@/components/chat/bot-chat-message-context-menu'; import { ChatPinnedMessageBanner } from '@/components/chat/chat-pinned-message-banner'; import { useRealtime } from '@/components/notifications/realtime-provider'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { BotChatMessage, BotChatMeta, fetchBotChatMessages, sendBotMessage } from '@/lib/api'; import { postEmbeddedAuthToIframe, EMBEDDED_AUTH_REQUEST } from '@/lib/embedded-auth-bridge'; import { AUTH_REFRESH_KEY, AUTH_SESSION_KEY, AUTH_TOKEN_KEY } from '@/lib/api'; import { normalizeMiniAppUrl, resolveMiniAppTargetOrigin } from '@/lib/mini-app-url'; import { extractMessageReplyMarkup, serializeInlineKeyboardMarkup } from '@/lib/bot-reply-markup'; import { resolveComposerMenuButton, type ComposerMenuButton } from '@/lib/bot-menu-button'; import { cn } from '@/lib/utils'; import { useToast } from '@/components/id/toast-provider'; function formatMessageTime(value: string) { return new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' }).format(new Date(value)); } function resolveBotRef(botRef: string, payloadBotUsername?: unknown) { if (typeof payloadBotUsername === 'string' && payloadBotUsername.trim()) { return payloadBotUsername.trim(); } return botRef; } function markupJsonFromPayload(payload: Record) { const rows = extractMessageReplyMarkup({ replyMarkup: payload.replyMarkup, telegramReplyMarkup: payload.telegramReplyMarkup, reply_markup: payload.reply_markup }); return serializeInlineKeyboardMarkup(rows); } interface FamilyBotChatProps { botRef: string; botName: string; token: string | null; roomId?: string; className?: string; inputPlaceholder?: string; onOpenMiniApp?: (url: string) => void; onBotMetaChange?: (meta: BotChatMeta) => void; } export function FamilyBotChat({ botRef, botName, token, roomId, className, inputPlaceholder = 'Сообщение боту', onOpenMiniApp, onBotMetaChange }: FamilyBotChatProps) { const [messages, setMessages] = useState([]); const [draft, setDraft] = useState(''); const [loading, setLoading] = useState(true); const [sending, setSending] = useState(false); const [composerMenuButton, setComposerMenuButton] = useState(null); const [internalMiniAppUrl, setInternalMiniAppUrl] = useState(null); const [botMeta, setBotMeta] = useState(null); const { subscribe } = useRealtime(); const { showToast } = useToast(); const pinnedMessage = messages .filter((message) => message.isPinned) .sort((left, right) => new Date(right.pinnedAt ?? right.createdAt).getTime() - new Date(left.pinnedAt ?? left.createdAt).getTime())[0] ?? null; function scrollToBotMessage(messageId: string) { const element = document.getElementById(`bot-msg-${messageId}`); if (!element) return false; element.scrollIntoView({ behavior: 'smooth', block: 'center' }); element.classList.add('blink-highlight'); window.setTimeout(() => element.classList.remove('blink-highlight'), 2000); return true; } const openMiniApp = useCallback( (url: string) => { if (onOpenMiniApp) { onOpenMiniApp(url); return; } setInternalMiniAppUrl(url); }, [onOpenMiniApp] ); const loadMessages = useCallback(async () => { if (!token) return; setLoading(true); try { const response = await fetchBotChatMessages(botRef, token, roomId); setMessages(response.messages ?? []); const menuButton = resolveComposerMenuButton({ composerMenuButtonJson: response.composerMenuButtonJson, composerWebAppUrl: response.composerWebAppUrl }); setComposerMenuButton(menuButton); const meta: BotChatMeta = { botId: response.botId, botOwnerId: response.botOwnerId, botUsername: response.botUsername, botDisplayName: response.botDisplayName, composerWebAppUrl: menuButton?.web_app.url ?? (response.composerWebAppUrl?.trim() || null), composerMenuButtonJson: response.composerMenuButtonJson ?? (menuButton ? JSON.stringify(menuButton) : null), manageWebAppUrl: response.manageWebAppUrl }; setBotMeta(meta); onBotMetaChange?.(meta); } finally { setLoading(false); } }, [botRef, onBotMetaChange, roomId, token]); useEffect(() => { void loadMessages(); }, [loadMessages]); useEffect(() => { return subscribe((event) => { const payload = event.payload ?? {}; const eventBotRef = resolveBotRef(botRef, payload.botUsername); if (eventBotRef !== botRef && eventBotRef.replace(/_bot$/i, '') !== botRef.replace(/_bot$/i, '')) { return; } const eventScope = payload.scope === 'broadcast' ? 'broadcast' : 'private'; const eventRoomId = typeof payload.roomId === 'string' ? payload.roomId : null; if (eventScope === 'broadcast') { if (roomId && eventRoomId && eventRoomId !== roomId) { return; } } else if (roomId) { // Личные сообщения бота не транслируются другим участникам комнаты. } if (event.type === 'bot_message') { const messageId = Number(payload.messageId); if (!Number.isFinite(messageId)) { void loadMessages(); return; } const replyMarkupJson = markupJsonFromPayload(payload); const outbound: BotChatMessage = { id: `${eventScope === 'broadcast' ? 'broadcast' : 'out'}-ws-${messageId}`, direction: 'out', scope: eventScope, text: typeof payload.text === 'string' ? payload.text : '', messageType: typeof payload.messageType === 'string' ? payload.messageType : 'text', messageId, createdAt: new Date().toISOString(), replyMarkupJson, isPinned: Boolean(payload.isPinned), pinnedAt: typeof payload.pinnedAt === 'string' ? payload.pinnedAt : null }; setMessages((current) => { const withoutDuplicate = current.filter( (item) => item.messageId !== messageId || item.direction !== 'out' || item.scope !== eventScope ); return [...withoutDuplicate, outbound].sort((a, b) => a.createdAt.localeCompare(b.createdAt)); }); return; } if (event.type === 'bot_menu_button_updated') { const menuButton = resolveComposerMenuButton({ composerMenuButtonJson: typeof payload.composerMenuButtonJson === 'string' ? payload.composerMenuButtonJson : null, composerWebAppUrl: typeof payload.composerWebAppUrl === 'string' ? payload.composerWebAppUrl : null }); setComposerMenuButton(menuButton); setBotMeta((current) => current ? { ...current, composerWebAppUrl: menuButton?.web_app.url ?? null, composerMenuButtonJson: menuButton ? JSON.stringify(menuButton) : null } : current ); return; } if (event.type === 'bot_message_edited') { const messageId = Number(payload.messageId); if (!Number.isFinite(messageId)) { void loadMessages(); return; } const replyMarkupJson = markupJsonFromPayload(payload); setMessages((current) => current.map((item) => item.messageId === messageId && item.direction === 'out' && (item.scope ?? 'private') === eventScope ? { ...item, text: typeof payload.text === 'string' ? payload.text : item.text, replyMarkupJson: replyMarkupJson ?? item.replyMarkupJson ?? null } : item ) ); return; } if (event.type === 'bot_message_pinned') { const messageId = Number(payload.messageId); if (!Number.isFinite(messageId)) { void loadMessages(); return; } setMessages((current) => current.map((item) => item.messageId === messageId && item.direction === 'out' && (item.scope ?? 'private') === eventScope ? { ...item, isPinned: Boolean(payload.isPinned), pinnedAt: typeof payload.pinnedAt === 'string' ? payload.pinnedAt : null } : item ) ); } }); }, [botRef, loadMessages, roomId, subscribe]); async function handleSend() { if (!token || !draft.trim() || sending) return; const text = draft.trim(); setSending(true); setDraft(''); setMessages((current) => [ ...current, { id: `in-local-${Date.now()}`, direction: 'in', text, messageType: 'text', messageId: 0, createdAt: new Date().toISOString() } ]); try { await sendBotMessage(botRef, text, token, roomId); await loadMessages(); } finally { setSending(false); } } return (
{pinnedMessage ? ( { if (!scrollToBotMessage(pinnedMessage.id)) { showToast('Закреплённое сообщение пока не загружено в ленте'); } }} /> ) : null}
{loading ? (
Загрузка...
) : messages.length ? ( messages.map((message) => { const mine = message.direction === 'in'; const isBroadcast = message.scope === 'broadcast'; const hasKeyboard = Boolean(message.replyMarkupJson?.trim()); return (
showToast('Текст скопирован')}>
{!mine ? (

{botName} {isBroadcast ? ' · уведомление' : ''}

) : null}

{message.text || '…'}

{!mine && hasKeyboard && message.messageId > 0 ? ( ) : null}

{formatMessageTime(message.createdAt)}

); }) ) : (

Напишите /help, чтобы начать

)}
{composerMenuButton ? ( ) : null} setDraft(event.target.value)} placeholder={inputPlaceholder} className="rounded-xl" onKeyDown={(event) => { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); void handleSend(); } }} />
setInternalMiniAppUrl(null)} />
); } interface BotMiniAppSheetProps { url: string | null; title?: string; authToken?: string | null; authRefreshToken?: string | null; authSessionId?: string | null; onClose: () => void; } export function BotMiniAppSheet({ url, title = 'Mini App', authToken, authRefreshToken, authSessionId, onClose }: BotMiniAppSheetProps) { const iframeRef = useRef(null); const normalizedUrl = url ? normalizeMiniAppUrl(url) : null; const resolvedAuthToken = authToken ?? (typeof window !== 'undefined' ? window.localStorage.getItem(AUTH_TOKEN_KEY) : null); const pushAuthToIframe = useCallback(() => { if (!iframeRef.current || !resolvedAuthToken) return; postEmbeddedAuthToIframe(iframeRef.current, { token: resolvedAuthToken, refreshToken: authRefreshToken ?? window.localStorage.getItem(AUTH_REFRESH_KEY), sessionId: authSessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) }); }, [authRefreshToken, authSessionId, resolvedAuthToken]); useEffect(() => { function handleMessage(event: MessageEvent) { const iframeOrigin = iframeRef.current ? resolveMiniAppTargetOrigin(iframeRef.current.getAttribute('src') || iframeRef.current.src, window.location.origin) : window.location.origin; if (event.origin !== iframeOrigin && event.origin !== window.location.origin) return; if (event.data?.type !== EMBEDDED_AUTH_REQUEST) return; pushAuthToIframe(); } window.addEventListener('message', handleMessage); return () => window.removeEventListener('message', handleMessage); }, [pushAuthToIframe]); useEffect(() => { if (!normalizedUrl || !resolvedAuthToken) return; pushAuthToIframe(); const interval = window.setInterval(pushAuthToIframe, 350); const timeout = window.setTimeout(() => window.clearInterval(interval), 3500); return () => { window.clearInterval(interval); window.clearTimeout(timeout); }; }, [normalizedUrl, pushAuthToIframe, resolvedAuthToken]); if (!normalizedUrl) return null; return (

{title}