diff --git a/apps/bot/src/bot/features/download.ts b/apps/bot/src/bot/features/download.ts index b1a1923..222cf5f 100644 --- a/apps/bot/src/bot/features/download.ts +++ b/apps/bot/src/bot/features/download.ts @@ -4,20 +4,121 @@ import { logHandle } from '../helpers/logging'; import { TTL_URLS } from '@/config/redis'; import { getInstagramDownloadUrl } from '@/utils/instagram'; import { getRedisInstance } from '@/utils/redis'; +import { removeHashtags } from '@/utils/text'; import { getTiktokDownloadUrl } from '@/utils/tiktok'; import { validateInstagramUrl, validateTikTokUrl, validateYoutubeUrl } from '@/utils/urls'; import { getYoutubeDownloadUrl } from '@/utils/youtube'; +import { code, expandableBlockquote, fmt } from '@grammyjs/parse-mode'; import { Composer, InputFile } from 'grammy'; import { cluster } from 'radashi'; const composer = new Composer(); const feature = composer.chatType('private'); - const redis = getRedisInstance(); +// Проверить кэш и, при наличии, ответить видеo/подписью. +// Возвращает { contentMessageId?, captionSent? } +async function checkCacheAndReply(context: Context, url: string) { + let contentMessageId: number | undefined; + + const cachedVideoId = await redis.get(url); + if (cachedVideoId) { + const cachedMessage = await context.replyWithVideo(cachedVideoId); + contentMessageId = cachedMessage.message_id; + } + + if (contentMessageId) { + const cachedCaption = await redis.get(`caption:${url}`); + if (cachedCaption) { + const { entities, text } = formatCaption(cachedCaption); + + if (text.trim().length) + await context.reply(text, { + entities, + reply_parameters: contentMessageId ? { message_id: contentMessageId } : undefined, + }); + return { captionSent: true, contentMessageId }; + } + } + + return { contentMessageId }; +} + +// Форматирование подписи как expandable blockquote +function formatCaption(caption: string) { + const cleanCaption = removeHashtags(caption); + return fmt`${expandableBlockquote} ${code} ${cleanCaption} ${code} ${expandableBlockquote}`; +} + +// Отправка подписи и запись в кэш +async function sendCaptionAndCache( + context: Context, + caption: string | undefined, + url: string, + contentMessageId?: number, +) { + if (!caption) return; + + const { entities, text } = formatCaption(caption); + await redis.set(`caption:${url}`, caption, 'EX', TTL_URLS); + + if (text.trim().length) + await context.reply(text, { + entities, + reply_parameters: contentMessageId ? { message_id: contentMessageId } : undefined, + }); +} + +// Отправка изображений (порциями). Возвращает contentMessageId (если установлен) +async function sendImages( + context: Context, + imagesUrls: string[], + existingContentMessageId?: number, +) { + if (!imagesUrls?.length) return existingContentMessageId; + + const chunks = cluster(imagesUrls, 10); + let contentMessageId = existingContentMessageId; + + for (const chunk of chunks) { + const imageMessages = await context.replyWithMediaGroup( + chunk.map((imageUrl) => ({ media: imageUrl, type: 'photo' })), + ); + + if (!contentMessageId && imageMessages.length) { + contentMessageId = imageMessages.at(0)?.message_id; + } + } + + return contentMessageId; +} + +// Отправка видео и запись в кэш (только если видео отправлено впервые) +async function sendVideoAndCache( + context: Context, + videoUrl: string | undefined, + url: string, + existingContentMessageId?: number, +) { + let contentMessageId = existingContentMessageId; + + if (videoUrl && !contentMessageId) { + const { video, ...videoMessage } = await context.replyWithVideo( + new InputFile({ url: videoUrl }), + ); + contentMessageId = videoMessage.message_id; + + // сохраняем file_id полученного видео + await redis.set(url, video.file_id, 'EX', TTL_URLS); + } + + return contentMessageId; +} + feature.on('message:text', logHandle('download-message'), async (context) => { const url = context.message.text.trim(); + // Проверка поддерживаемых сервисов const isTikTok = validateTikTokUrl(url); const isInstagram = validateInstagramUrl(url); const isYoutube = validateYoutubeUrl(url); @@ -28,30 +129,33 @@ feature.on('message:text', logHandle('download-message'), async (context) => { return context.reply(context.t('err-invalid-url')); } - const cachedFileId = await redis.get(url); - if (cachedFileId) { - return context.replyWithVideo(cachedFileId); - } + // Проверка кеша и быстрый ответ, если есть подпись в кеше + const cacheResult = await checkCacheAndReply(context, url); + if (cacheResult.captionSent) return; + let contentMessageId = cacheResult.contentMessageId; + // Загрузка данных с сервисов let imagesUrls: string[] | undefined; let videoUrl: string | undefined; + let caption: string | undefined; try { if (isTikTok) { const result = await getTiktokDownloadUrl(url); imagesUrls = result.images; videoUrl = result.play; + caption = result.title; } else if (isInstagram) { const result = await getInstagramDownloadUrl(url); imagesUrls = result.images; videoUrl = result.play; + caption = result.caption; } else if (isYoutube) { const result = await getYoutubeDownloadUrl(url); videoUrl = result.play; } - } catch (error_: unknown) { - const error = error_ as Error; - const message = error?.message ?? String(error); + } catch (error: unknown) { + const message = (error as Error)?.message ?? String(error); if (typeof message === 'string' && message.startsWith('err-')) { return context.reply(context.t(message)); } @@ -63,21 +167,14 @@ feature.on('message:text', logHandle('download-message'), async (context) => { return context.reply(context.t('err-invalid-download-urls')); } - if (imagesUrls?.length) { - const chunks = cluster(imagesUrls, 10); - for (const chunk of chunks) { - await context.replyWithMediaGroup( - chunk.map((imageUrl) => ({ media: imageUrl, type: 'photo' })), - ); - } + // Отправка изображений + contentMessageId = await sendImages(context, imagesUrls ?? [], contentMessageId); - return; - } + // Отправка видео (если ещё не отправлено) и запись в кэш + contentMessageId = await sendVideoAndCache(context, videoUrl, url, contentMessageId); - if (videoUrl) { - const { video } = await context.replyWithVideo(new InputFile({ url: videoUrl })); - await redis.set(url, video.file_id, 'EX', TTL_URLS); - } + // Отправка описания и запись в кэш + await sendCaptionAndCache(context, caption, url, contentMessageId); }); export { composer as download }; diff --git a/apps/bot/src/constants/regex.ts b/apps/bot/src/constants/regex.ts index a803d7e..a28c20b 100644 --- a/apps/bot/src/constants/regex.ts +++ b/apps/bot/src/constants/regex.ts @@ -7,3 +7,5 @@ export const INSTAGRAM_URL_REGEX = export const YOUTUBE_URL_REGEX = /(?:https?:\/\/)?(?:www\.)?(?:youtu\.be\/|youtube\.com\/(?:watch\?v=|embed\/|v\/|shorts\/))([\w-]{11})(?:[?&]\S*)?/u; + +export const TAGS = /#[\p{L}\p{N}_]+/gu; diff --git a/apps/bot/src/utils/instagram.ts b/apps/bot/src/utils/instagram.ts index f264e81..6664489 100644 --- a/apps/bot/src/utils/instagram.ts +++ b/apps/bot/src/utils/instagram.ts @@ -14,6 +14,7 @@ export type CarouselItem = { export type Root = { authorInfo: AuthorInfo; + caption: string; carouselItems: CarouselItem[]; id: number; mediaUrls: string[]; @@ -33,12 +34,14 @@ export async function getInstagramDownloadUrl(url: string) { if (isVideo) { return { + caption: data.caption, images: [], play: data.mediaUrls.at(0), }; } return { + caption: data.caption, images: data.mediaUrls, play: undefined, }; diff --git a/apps/bot/src/utils/text.ts b/apps/bot/src/utils/text.ts new file mode 100644 index 0000000..fc4b61c --- /dev/null +++ b/apps/bot/src/utils/text.ts @@ -0,0 +1,3 @@ +export function removeHashtags(caption: string): string { + return caption.replaceAll(/#[\p{L}\p{N}_-]+/gu, '').trim(); +}