Compare commits
5 Commits
master
...
feature/se
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebff5a7ce1 | ||
|
|
f48230cbeb | ||
|
|
816eca4da5 | ||
|
|
58dc5f5ae3 | ||
|
|
6e9d2e9b9e |
@ -4,20 +4,121 @@ import { logHandle } from '../helpers/logging';
|
|||||||
import { TTL_URLS } from '@/config/redis';
|
import { TTL_URLS } from '@/config/redis';
|
||||||
import { getInstagramDownloadUrl } from '@/utils/instagram';
|
import { getInstagramDownloadUrl } from '@/utils/instagram';
|
||||||
import { getRedisInstance } from '@/utils/redis';
|
import { getRedisInstance } from '@/utils/redis';
|
||||||
|
import { removeHashtags } from '@/utils/text';
|
||||||
import { getTiktokDownloadUrl } from '@/utils/tiktok';
|
import { getTiktokDownloadUrl } from '@/utils/tiktok';
|
||||||
import { validateInstagramUrl, validateTikTokUrl, validateYoutubeUrl } from '@/utils/urls';
|
import { validateInstagramUrl, validateTikTokUrl, validateYoutubeUrl } from '@/utils/urls';
|
||||||
import { getYoutubeDownloadUrl } from '@/utils/youtube';
|
import { getYoutubeDownloadUrl } from '@/utils/youtube';
|
||||||
|
import { code, expandableBlockquote, fmt } from '@grammyjs/parse-mode';
|
||||||
import { Composer, InputFile } from 'grammy';
|
import { Composer, InputFile } from 'grammy';
|
||||||
import { cluster } from 'radashi';
|
import { cluster } from 'radashi';
|
||||||
|
|
||||||
const composer = new Composer<Context>();
|
const composer = new Composer<Context>();
|
||||||
const feature = composer.chatType('private');
|
const feature = composer.chatType('private');
|
||||||
|
|
||||||
const redis = getRedisInstance();
|
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) => {
|
feature.on('message:text', logHandle('download-message'), async (context) => {
|
||||||
const url = context.message.text.trim();
|
const url = context.message.text.trim();
|
||||||
|
|
||||||
|
// Проверка поддерживаемых сервисов
|
||||||
const isTikTok = validateTikTokUrl(url);
|
const isTikTok = validateTikTokUrl(url);
|
||||||
const isInstagram = validateInstagramUrl(url);
|
const isInstagram = validateInstagramUrl(url);
|
||||||
const isYoutube = validateYoutubeUrl(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'));
|
return context.reply(context.t('err-invalid-url'));
|
||||||
}
|
}
|
||||||
|
|
||||||
const cachedFileId = await redis.get(url);
|
// Проверка кеша и быстрый ответ, если есть подпись в кеше
|
||||||
if (cachedFileId) {
|
const cacheResult = await checkCacheAndReply(context, url);
|
||||||
return context.replyWithVideo(cachedFileId);
|
if (cacheResult.captionSent) return;
|
||||||
}
|
let contentMessageId = cacheResult.contentMessageId;
|
||||||
|
|
||||||
|
// Загрузка данных с сервисов
|
||||||
let imagesUrls: string[] | undefined;
|
let imagesUrls: string[] | undefined;
|
||||||
let videoUrl: string | undefined;
|
let videoUrl: string | undefined;
|
||||||
|
let caption: string | undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isTikTok) {
|
if (isTikTok) {
|
||||||
const result = await getTiktokDownloadUrl(url);
|
const result = await getTiktokDownloadUrl(url);
|
||||||
imagesUrls = result.images;
|
imagesUrls = result.images;
|
||||||
videoUrl = result.play;
|
videoUrl = result.play;
|
||||||
|
caption = result.title;
|
||||||
} else if (isInstagram) {
|
} else if (isInstagram) {
|
||||||
const result = await getInstagramDownloadUrl(url);
|
const result = await getInstagramDownloadUrl(url);
|
||||||
imagesUrls = result.images;
|
imagesUrls = result.images;
|
||||||
videoUrl = result.play;
|
videoUrl = result.play;
|
||||||
|
caption = result.caption;
|
||||||
} else if (isYoutube) {
|
} else if (isYoutube) {
|
||||||
const result = await getYoutubeDownloadUrl(url);
|
const result = await getYoutubeDownloadUrl(url);
|
||||||
videoUrl = result.play;
|
videoUrl = result.play;
|
||||||
}
|
}
|
||||||
} catch (error_: unknown) {
|
} catch (error: unknown) {
|
||||||
const error = error_ as Error;
|
const message = (error as Error)?.message ?? String(error);
|
||||||
const message = error?.message ?? String(error);
|
|
||||||
if (typeof message === 'string' && message.startsWith('err-')) {
|
if (typeof message === 'string' && message.startsWith('err-')) {
|
||||||
return context.reply(context.t(message));
|
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'));
|
return context.reply(context.t('err-invalid-download-urls'));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (imagesUrls?.length) {
|
// Отправка изображений
|
||||||
const chunks = cluster(imagesUrls, 10);
|
contentMessageId = await sendImages(context, imagesUrls ?? [], contentMessageId);
|
||||||
for (const chunk of chunks) {
|
|
||||||
await context.replyWithMediaGroup(
|
|
||||||
chunk.map((imageUrl) => ({ media: imageUrl, type: 'photo' })),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
// Отправка видео (если ещё не отправлено) и запись в кэш
|
||||||
}
|
contentMessageId = await sendVideoAndCache(context, videoUrl, url, contentMessageId);
|
||||||
|
|
||||||
if (videoUrl) {
|
// Отправка описания и запись в кэш
|
||||||
const { video } = await context.replyWithVideo(new InputFile({ url: videoUrl }));
|
await sendCaptionAndCache(context, caption, url, contentMessageId);
|
||||||
await redis.set(url, video.file_id, 'EX', TTL_URLS);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export { composer as download };
|
export { composer as download };
|
||||||
|
|||||||
@ -7,3 +7,5 @@ export const INSTAGRAM_URL_REGEX =
|
|||||||
|
|
||||||
export const YOUTUBE_URL_REGEX =
|
export const YOUTUBE_URL_REGEX =
|
||||||
/(?:https?:\/\/)?(?:www\.)?(?:youtu\.be\/|youtube\.com\/(?:watch\?v=|embed\/|v\/|shorts\/))([\w-]{11})(?:[?&]\S*)?/u;
|
/(?:https?:\/\/)?(?:www\.)?(?:youtu\.be\/|youtube\.com\/(?:watch\?v=|embed\/|v\/|shorts\/))([\w-]{11})(?:[?&]\S*)?/u;
|
||||||
|
|
||||||
|
export const TAGS = /#[\p{L}\p{N}_]+/gu;
|
||||||
|
|||||||
@ -14,6 +14,7 @@ export type CarouselItem = {
|
|||||||
|
|
||||||
export type Root = {
|
export type Root = {
|
||||||
authorInfo: AuthorInfo;
|
authorInfo: AuthorInfo;
|
||||||
|
caption: string;
|
||||||
carouselItems: CarouselItem[];
|
carouselItems: CarouselItem[];
|
||||||
id: number;
|
id: number;
|
||||||
mediaUrls: string[];
|
mediaUrls: string[];
|
||||||
@ -33,12 +34,14 @@ export async function getInstagramDownloadUrl(url: string) {
|
|||||||
|
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
return {
|
return {
|
||||||
|
caption: data.caption,
|
||||||
images: [],
|
images: [],
|
||||||
play: data.mediaUrls.at(0),
|
play: data.mediaUrls.at(0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
caption: data.caption,
|
||||||
images: data.mediaUrls,
|
images: data.mediaUrls,
|
||||||
play: undefined,
|
play: undefined,
|
||||||
};
|
};
|
||||||
|
|||||||
3
apps/bot/src/utils/text.ts
Normal file
3
apps/bot/src/utils/text.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export function removeHashtags(caption: string): string {
|
||||||
|
return caption.replaceAll(/#[\p{L}\p{N}_-]+/gu, '').trim();
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user