'use client'; import { useCallback, useEffect, useRef, useState } from 'react'; import { LayoutGrid, Loader2, Pin, Send, X } from 'lucide-react'; import { BotMessageKeyboard } from '@/components/chat/bot-message-keyboard'; import { BotChatMessageContextMenu } from '@/components/chat/bot-chat-message-context-menu'; 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; className?: string; inputPlaceholder?: string; onOpenMiniApp?: (url: string) => void; onBotMetaChange?: (meta: BotChatMeta) => void; } export function FamilyBotChat({ botRef, botName, token, 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 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); 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, 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; } 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: `out-ws-${messageId}`, direction: 'out', 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'); 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, 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, isPinned: Boolean(payload.isPinned), pinnedAt: typeof payload.pinnedAt === 'string' ? payload.pinnedAt : null } : item ) ); } }); }, [botRef, loadMessages, 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); await loadMessages(); } finally { setSending(false); } } return (
{loading ? (
Загрузка...
) : messages.length ? ( messages.map((message) => { const mine = message.direction === 'in'; const hasKeyboard = Boolean(message.replyMarkupJson?.trim()); return (
showToast('Текст скопирован')}>
{message.isPinned ? (
Закреплено
) : null} {!mine ?

{botName}

: 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}