Compare commits

...

3 Commits

Author SHA1 Message Date
vchikalkin
8a8a21b155 refactor: extract download logic and clean up caption handling 2026-01-15 12:16:47 +03:00
vchikalkin
1f5b4073e0 refactor: remove unnecessary comments and improve code clarity in download feature 2026-01-15 12:04:57 +03:00
Vlad Chikalkin
8919fbb65f
Feature/send post description (#5)
* feat: enhance download feature with caption support for Instagram

* feat: improve message handling with enhanced caching and error management

* feat: add hashtag removal utility and integrate it into caption formatting

* fix: bot sent caption without content from cache

* feat: refactor Instagram download URL handling to simplify video response structure
2026-01-14 18:26:24 +03:00
4 changed files with 169 additions and 38 deletions

View File

@ -4,17 +4,157 @@ 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<Context>();
const feature = composer.chatType('private');
const redis = getRedisInstance();
type DownloadResult = {
caption?: string;
imagesUrls?: string[];
videoUrl?: string;
};
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: { message_id: contentMessageId },
});
}
return { captionSent: true, contentMessageId };
}
}
return { contentMessageId };
}
function formatCaption(caption: string) {
const cleanCaption = removeHashtags(caption);
return fmt`${expandableBlockquote} ${code} ${cleanCaption} ${code} ${expandableBlockquote}`;
}
async function getDownloadData(
url: string,
opts: {
isInstagram: boolean;
isTikTok: boolean;
isYoutube: boolean;
},
): Promise<DownloadResult> {
const { isInstagram, isTikTok, isYoutube } = opts;
if (isTikTok) {
const result = await getTiktokDownloadUrl(url);
return {
caption: result.title,
imagesUrls: result.images,
videoUrl: result.play,
};
}
if (isInstagram) {
const result = await getInstagramDownloadUrl(url);
return {
caption: result.caption,
imagesUrls: result.images,
videoUrl: result.play,
};
}
if (isYoutube) {
const result = await getYoutubeDownloadUrl(url);
return {
videoUrl: result.play,
};
}
return {};
}
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,
});
}
}
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;
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();
@ -22,36 +162,31 @@ feature.on('message:text', logHandle('download-message'), async (context) => {
const isInstagram = validateInstagramUrl(url);
const isYoutube = validateYoutubeUrl(url);
const isSupportedService = isTikTok || isInstagram || isYoutube;
if (!isSupportedService) {
if (!isTikTok && !isInstagram && !isYoutube) {
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;
} else if (isInstagram) {
const result = await getInstagramDownloadUrl(url);
imagesUrls = result.images;
videoUrl = result.play;
} 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);
const result = await getDownloadData(url, {
isInstagram,
isTikTok,
isYoutube,
});
imagesUrls = result.imagesUrls;
videoUrl = result.videoUrl;
caption = result.caption;
} 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 +198,9 @@ 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' })),
);
}
return;
}
if (videoUrl) {
const { video } = await context.replyWithVideo(new InputFile({ url: videoUrl }));
await redis.set(url, video.file_id, 'EX', TTL_URLS);
}
contentMessageId = await sendImages(context, imagesUrls ?? [], contentMessageId);
contentMessageId = await sendVideoAndCache(context, videoUrl, url, contentMessageId);
await sendCaptionAndCache(context, caption, url, contentMessageId);
});
export { composer as download };

View File

@ -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;

View File

@ -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,
};

View File

@ -0,0 +1,3 @@
export function removeHashtags(caption: string): string {
return caption.replaceAll(/#[\p{L}\p{N}_-]+/gu, '').trim();
}