Files
IdP/apps/frontend/components/family/family-bot-chat.tsx
2026-06-26 13:01:52 +03:00

398 lines
15 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.
'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<string, unknown>) {
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<BotChatMessage[]>([]);
const [draft, setDraft] = useState('');
const [loading, setLoading] = useState(true);
const [sending, setSending] = useState(false);
const [composerMenuButton, setComposerMenuButton] = useState<ComposerMenuButton | null>(null);
const [internalMiniAppUrl, setInternalMiniAppUrl] = useState<string | null>(null);
const [botMeta, setBotMeta] = useState<BotChatMeta | null>(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 (
<div className={cn('flex min-h-0 flex-1 flex-col', className)}>
<div className="flex-1 space-y-2 overflow-y-auto px-4 py-4">
{loading ? (
<div className="flex items-center justify-center gap-2 py-8 text-sm text-[#667085]">
<Loader2 className="h-4 w-4 animate-spin" />
Загрузка...
</div>
) : messages.length ? (
messages.map((message) => {
const mine = message.direction === 'in';
const hasKeyboard = Boolean(message.replyMarkupJson?.trim());
return (
<div key={message.id} className={cn('flex', mine ? 'justify-end' : 'justify-start')}>
<BotChatMessageContextMenu text={message.text} onCopy={() => showToast('Текст скопирован')}>
<div className={cn('max-w-[78%] rounded-[18px] px-3 py-2 shadow-sm', mine ? 'rounded-br-md bg-[#effdde]' : 'rounded-bl-md bg-white')}>
{message.isPinned ? (
<div className={cn('mb-1 flex items-center gap-1 text-[11px] font-medium', mine ? 'text-[#6c8f56]' : 'text-[#3390ec]')}>
<Pin className="h-3 w-3" />
Закреплено
</div>
) : null}
{!mine ? <p className="mb-0.5 text-[11px] font-semibold text-[#3390ec]">{botName}</p> : null}
<p className="whitespace-pre-wrap text-sm">{message.text || '…'}</p>
{!mine && hasKeyboard && message.messageId > 0 ? (
<BotMessageKeyboard
botRef={botRef}
botId={botMeta?.botId}
messageId={message.messageId}
markup={message.replyMarkupJson}
token={token}
onOpenMiniApp={openMiniApp}
/>
) : null}
<p className="mt-1 text-[10px] text-[#a8adbc]">{formatMessageTime(message.createdAt)}</p>
</div>
</BotChatMessageContextMenu>
</div>
);
})
) : (
<p className="py-8 text-center text-sm text-[#667085]">Напишите /help, чтобы начать</p>
)}
</div>
<div className="flex items-center gap-2 border-t border-[#dce3ec] bg-white px-4 py-3">
{composerMenuButton ? (
<Button
type="button"
variant="secondary"
className="h-10 shrink-0 rounded-full border border-[#3390ec]/20 bg-[#3390ec]/10 px-3 text-[#3390ec] transition hover:bg-[#3390ec]/15"
aria-label={composerMenuButton.text}
title={composerMenuButton.text}
onClick={() => openMiniApp(composerMenuButton.web_app.url)}
>
<LayoutGrid className="mr-1.5 h-4 w-4" />
<span className="max-w-[96px] truncate text-xs font-medium">{composerMenuButton.text}</span>
</Button>
) : null}
<Input
value={draft}
onChange={(event) => setDraft(event.target.value)}
placeholder={inputPlaceholder}
className="rounded-xl"
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
void handleSend();
}
}}
/>
<Button className="h-10 w-10 shrink-0 rounded-full p-0" disabled={sending || !draft.trim()} onClick={() => void handleSend()}>
{sending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
</Button>
</div>
<BotMiniAppSheet
url={internalMiniAppUrl}
title={composerMenuButton?.text ?? 'Mini App'}
authToken={token}
onClose={() => setInternalMiniAppUrl(null)}
/>
</div>
);
}
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<HTMLIFrameElement>(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 (
<div className="fixed inset-0 z-[70] flex items-end justify-center bg-black/40 p-4 sm:items-center">
<div className="flex h-[min(88vh,720px)] w-full max-w-[520px] flex-col overflow-hidden rounded-[24px] bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-[#eceef4] px-4 py-3">
<p className="font-semibold">{title}</p>
<Button type="button" variant="ghost" size="icon" className="h-8 w-8" aria-label="Закрыть" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
</div>
<iframe
ref={iframeRef}
src={normalizedUrl}
title={title}
className="min-h-0 flex-1 border-0"
allow="clipboard-read; clipboard-write"
onLoad={pushAuthToIframe}
/>
</div>
</div>
);
}