diff --git a/src/callback_commands/download-yt-video.ts b/src/callback_commands/download-yt-video.ts new file mode 100644 index 0000000..d867540 --- /dev/null +++ b/src/callback_commands/download-yt-video.ts @@ -0,0 +1,36 @@ +import {CallbackCommand} from "../base/callback-command"; +import {CallbackQuery} from "typescript-telegram-bot-api"; +import {Requirements} from "../base/requirements"; +import {Requirement} from "../base/requirement"; +import {commands} from "../index"; +import {YouTubeDownload} from "../commands/youtube-download"; + +const downloadText = " 📥 Скачать"; +const getFromCacheText = "📥 Загрузить из кэша"; + +export class DownloadYtVideo extends CallbackCommand { + data = "/ytdl"; + text = " 📥 Скачать"; + + requirements = Requirements.Build(Requirement.SAME_USER); + + constructor(text?: string, data?: string) { + super(); + + this.text = text || this.text; + this.data = data || this.data; + } + + static withData(inCache?: boolean, data?: string): DownloadYtVideo { + return new DownloadYtVideo(inCache ? getFromCacheText : downloadText, data); + } + + async execute(query: CallbackQuery): Promise { + const videoId = query.data.split(" ")[1]; + if (!videoId) return; + + const yt = commands.find(c => c instanceof YouTubeDownload); + if (!yt) return; + await yt.downloadYouTubeVideo(query.message, {videoId: videoId}); + } +} \ No newline at end of file diff --git a/src/callback_commands/try-again.ts b/src/callback_commands/try-again.ts new file mode 100644 index 0000000..29af38a --- /dev/null +++ b/src/callback_commands/try-again.ts @@ -0,0 +1,21 @@ +import {CallbackCommand} from "../base/callback-command"; + +export class TryAgain extends CallbackCommand { + data = ""; + text = "🔁 Повторить"; + + constructor(text?: string, data?: string) { + super(); + + this.text = text ?? this.text; + this.data = data ?? this.data; + } + + static withData(data?: string): TryAgain { + return new TryAgain(null, data); + } + + async execute(): Promise { + return Promise.resolve(); + } +} \ No newline at end of file diff --git a/src/callback_commands/yt-info.ts b/src/callback_commands/yt-info.ts new file mode 100644 index 0000000..b779b77 --- /dev/null +++ b/src/callback_commands/yt-info.ts @@ -0,0 +1,15 @@ +import {CallbackCommand} from "../base/callback-command"; +import {CallbackQuery} from "typescript-telegram-bot-api"; +import {processYouTubeLink} from "../util/utils"; + +export class YtInfo extends CallbackCommand { + data = "/ytinfo"; + text: string; + + async execute(query: CallbackQuery): Promise { + const videoUrl = query.data.split(" ")[1]; + if (!videoUrl) return; + + await processYouTubeLink(query.message, videoUrl); + } +} \ No newline at end of file diff --git a/src/commands/gemini-chat.ts b/src/commands/gemini-chat.ts index 548b20a..937cbb5 100644 --- a/src/commands/gemini-chat.ts +++ b/src/commands/gemini-chat.ts @@ -80,7 +80,7 @@ export class GeminiChat extends ChatCommand { try { waitMessage = await bot.sendMessage({ chat_id: chatId, - text: Environment.waitText, + text: Environment.waitThinkText, reply_parameters: { chat_id: chatId, message_id: msg.message_id diff --git a/src/commands/gemini-generate-image.ts b/src/commands/gemini-generate-image.ts index 93c66a9..68932a1 100644 --- a/src/commands/gemini-generate-image.ts +++ b/src/commands/gemini-generate-image.ts @@ -53,7 +53,7 @@ export class GeminiGenerateImage extends Command { await replyToMessage({ message: waitMessage, text: `Произошла ошибка!\n${e.toString()}`, - disableLinkPreview: true + link_preview_options: {is_disabled: true} }).catch(logError); } } diff --git a/src/commands/mistral-chat.ts b/src/commands/mistral-chat.ts index 0c088ab..d5b72a7 100644 --- a/src/commands/mistral-chat.ts +++ b/src/commands/mistral-chat.ts @@ -90,7 +90,7 @@ export class MistralChat extends ChatCommand { chat_id: chatId, text: imagesCount ? imagesCount > 1 ? Environment.analyzingPicturesText : Environment.analyzingPictureText - : Environment.waitText, + : Environment.waitThinkText, reply_parameters: { chat_id: chatId, diff --git a/src/commands/ollama-chat.ts b/src/commands/ollama-chat.ts index 9e7b1ad..5330e8a 100644 --- a/src/commands/ollama-chat.ts +++ b/src/commands/ollama-chat.ts @@ -96,7 +96,7 @@ export class OllamaChat extends ChatCommand { message: msg, text: (!think && imagesCount) ? imagesCount > 1 ? Environment.analyzingPicturesText : Environment.analyzingPictureText - : Environment.waitText + : Environment.waitThinkText }); const stream = await ollama.chat({ diff --git a/src/commands/ollama-prompt.ts b/src/commands/ollama-prompt.ts index aaedd5c..8a1cd0f 100644 --- a/src/commands/ollama-prompt.ts +++ b/src/commands/ollama-prompt.ts @@ -37,7 +37,7 @@ export class OllamaPrompt extends Command { waitMessage = await bot.sendMessage({ chat_id: chatId, - text: Environment.waitText, + text: Environment.waitThinkText, reply_parameters: { chat_id: chatId, message_id: msg.message_id diff --git a/src/commands/ollama-search.ts b/src/commands/ollama-search.ts index 2d1a47e..c3a5634 100644 --- a/src/commands/ollama-search.ts +++ b/src/commands/ollama-search.ts @@ -4,7 +4,7 @@ import {Requirement} from "../base/requirement"; import {Message} from "typescript-telegram-bot-api"; import {bot, ollama} from "../index"; import {WebSearchResponse} from "../model/web-search-response"; -import {editMessageText, logError} from "../util/utils"; +import {oldEditMessageText, logError} from "../util/utils"; import {Environment} from "../common/environment"; export class OllamaSearch extends Command { @@ -23,7 +23,7 @@ export class OllamaSearch extends Command { try { const wait = await bot.sendMessage({ chat_id: chatId, - text: Environment.waitText, + text: Environment.waitThinkText, reply_parameters: { chat_id: chatId, message_id: msg.message_id @@ -40,7 +40,7 @@ export class OllamaSearch extends Command { message += `${index + 1}. ${r.url}\n`; }); - await editMessageText(chatId, wait.message_id, message); + await oldEditMessageText(chatId, wait.message_id, message); } catch (error) { logError(error); } diff --git a/src/commands/openai-chat.ts b/src/commands/openai-chat.ts index 0912a96..c171f61 100644 --- a/src/commands/openai-chat.ts +++ b/src/commands/openai-chat.ts @@ -71,7 +71,7 @@ export class OpenAIChat extends ChatCommand { try { waitMessage = await bot.sendMessage({ chat_id: chatId, - text: Environment.waitText, + text: Environment.waitThinkText, reply_parameters: { chat_id: chatId, message_id: msg.message_id diff --git a/src/commands/openai-gen-image.ts b/src/commands/openai-gen-image.ts index ff2dbdd..b39e445 100644 --- a/src/commands/openai-gen-image.ts +++ b/src/commands/openai-gen-image.ts @@ -5,7 +5,7 @@ import {Requirement} from "../base/requirement"; import {bot, openAi, photoGenDir} from "../index"; import fs from "node:fs"; import path from "node:path"; -import {editMessageText, logError, replyToMessage} from "../util/utils"; +import {oldEditMessageText, logError, replyToMessage} from "../util/utils"; import {Environment} from "../common/environment"; import {APIError} from "openai"; @@ -102,7 +102,7 @@ export class OpenAIGenImage extends ChatCommand { const text = "❌ Мне запрещено такое генерировать 😠"; if (waitMessage) { - await editMessageText(msg.chat.id, waitMessage.message_id, text).catch(logError); + await oldEditMessageText(msg.chat.id, waitMessage.message_id, text).catch(logError); } else { await replyToMessage({message: msg, text: text}).catch(logError); } diff --git a/src/commands/youtube-download.ts b/src/commands/youtube-download.ts index b5adff3..15f20b6 100644 --- a/src/commands/youtube-download.ts +++ b/src/commands/youtube-download.ts @@ -1,8 +1,10 @@ import {Command} from "../base/command"; import {Message} from "typescript-telegram-bot-api"; -import {logError, replyToMessage} from "../util/utils"; -import {bot} from "../index"; -import {downloadVideoFromYouTube} from "../util/ytdl"; +import {editMessageText, logError, replyToMessage} from "../util/utils"; +import {bot, botUser} from "../index"; +import {DownloadOptions, downloadVideoFromYouTube, getYouTubeVideoId} from "../util/ytdl"; +import {Environment} from "../common/environment"; +import {TryAgain} from "../callback_commands/try-again"; export class YouTubeDownload extends Command { command = ["ytdl", "youtube"]; @@ -10,16 +12,22 @@ export class YouTubeDownload extends Command { async execute(msg: Message, match?: RegExpExecArray): Promise { const url = match?.[3]; - return this.downloadYouTubeVideo(msg, url); + return this.downloadYouTubeVideo(msg, {url: url}); } - async downloadYouTubeVideo(msg: Message, url: string): Promise { - let waitMessage: Message | null = null; + async downloadYouTubeVideo(msg: Message, options: DownloadOptions): Promise { + // TODO: 02.03.2026, Danil Nikolaev: add check for date + let waitMessage: Message | null = (msg.from.id === botUser.id) ? msg : null; + const videoId = "videoId" in options ? options.videoId : getYouTubeVideoId(options.url); try { - waitMessage = await replyToMessage({message: msg, text: "⏳ Секунду..."}); + if (!waitMessage) { + waitMessage = await replyToMessage({message: msg, text: Environment.waitText}); + } else { + await editMessageText({message: msg, text: Environment.waitText}); + } - const {time, exists, buffer} = await downloadVideoFromYouTube(url); + const {time, exists, buffer} = await downloadVideoFromYouTube({videoId: videoId}); if (buffer) { const start = Date.now(); waitMessage = await bot.editMessageMedia({ @@ -35,7 +43,7 @@ export class YouTubeDownload extends Command { waitMessage = await bot.editMessageCaption({ chat_id: msg.chat.id, message_id: waitMessage.message_id, - caption: `✅ [Видео](${url})` + (exists ? " загружено из кэша" : " успешно скачано") + " за " + (time + diff) + "мс", + caption: "✅ [Видео]" + (exists ? " загружено из кэша" : " успешно скачано") + " за " + (time + diff) + "мс", parse_mode: "MarkdownV2" }) as Message; } @@ -46,7 +54,12 @@ export class YouTubeDownload extends Command { await bot.editMessageText({ chat_id: msg.chat.id, message_id: waitMessage.message_id, - text: `⚠️ Произошла ошибка.\n${e}`, + text: Environment.errorText, + reply_markup: { + inline_keyboard: [[ + TryAgain.withData("/ytdl " + videoId).asButton() + ]] + } }); } } diff --git a/src/common/environment.ts b/src/common/environment.ts index 4be1761..047e8df 100644 --- a/src/common/environment.ts +++ b/src/common/environment.ts @@ -27,6 +27,8 @@ export class Environment { static MAX_PHOTO_SIZE: number; + static PROCESS_LINKS: boolean; + static DEFAULT_AI_PROVIDER: AiProvider; static SYSTEM_PROMPT?: string; @@ -49,7 +51,9 @@ export class Environment { static OPENAI_MODEL: string; static OPENAI_IMAGE_MODEL: string; - static waitText = "⏳ Дайте-ка подумать..."; + static errorText = "⚠️ Произошла ошибка."; + static waitText = "⏳ Секунду..."; + static waitThinkText = "⏳ Дайте-ка подумать..."; static analyzingPictureText = "🔍 Внимательно изучаю изображение..."; static analyzingPicturesText = "🔍 Внимательно изучаю изображения..."; static genImageText = "👨‍🎨 Генерирую изображение..."; @@ -73,6 +77,8 @@ export class Environment { Environment.MAX_PHOTO_SIZE = Number(process.env.MAX_PHOTO_SIZE || "1280"); + Environment.PROCESS_LINKS = ifTrue(process.env.PROCESS_LINKS); + const aiProvider = process.env.DEFAULT_AI_PROVIDER || "OLLAMA"; if (Object.values(AiProvider).includes(aiProvider as AiProvider)) { Environment.DEFAULT_AI_PROVIDER = aiProvider as AiProvider; diff --git a/src/index.ts b/src/index.ts index 479d5ed..6c213e0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -79,6 +79,8 @@ import {OpenAISetModel} from "./commands/openai-set-model"; import {Info} from "./commands/info"; import {OpenAIGenImage} from "./commands/openai-gen-image"; import {clearUpFolderFromOldFiles} from "./util/files"; +import {DownloadYtVideo} from "./callback_commands/download-yt-video"; +import {YtInfo} from "./callback_commands/yt-info"; process.setUncaughtExceptionCaptureCallback(logError); @@ -171,7 +173,9 @@ if (Environment.ENABLE_UNSAFE_EVAL) { } export const callbackCommands: CallbackCommand[] = [ - new OllamaCancel() + new OllamaCancel(), + new DownloadYtVideo(), + new YtInfo() ]; if (Environment.OLLAMA_ADDRESS && Environment.OLLAMA_MODEL && Environment.SYSTEM_PROMPT) { diff --git a/src/model/edit-options.ts b/src/model/edit-options.ts new file mode 100644 index 0000000..ee2a1df --- /dev/null +++ b/src/model/edit-options.ts @@ -0,0 +1,15 @@ +import {InlineKeyboardMarkup, Message, ParseMode} from "typescript-telegram-bot-api"; +import {LinkPreviewOptions, MessageEntity} from "typescript-telegram-bot-api/dist/types"; + +export type EditOptions = ({ + message: Message +} | { + chat_id: number; + message_id: number; +}) & { + text: string; + parse_mode?: ParseMode; + entities?: MessageEntity[]; + link_preview_options?: LinkPreviewOptions; + reply_markup?: InlineKeyboardMarkup; +} \ No newline at end of file diff --git a/src/model/send-options.ts b/src/model/send-options.ts new file mode 100644 index 0000000..51fdb2b --- /dev/null +++ b/src/model/send-options.ts @@ -0,0 +1,77 @@ +import {InlineKeyboardMarkup, Message, ParseMode} from "typescript-telegram-bot-api"; +import { + ForceReply, + LinkPreviewOptions, + MessageEntity, ReplyKeyboardMarkup, ReplyKeyboardRemove, + ReplyParameters, + SuggestedPostParameters +} from "typescript-telegram-bot-api/dist/types"; + +export type SendOptions = ({ + message: Message +} | { + /** + * Unique identifier for the target chat or username of the target channel (in the format `@channelusername`) + */ + chat_id: number | string; + message_id?: number; +}) & { + /** + * Unique identifier for the target message thread (topic) of the forum; for forum supergroups only + */ + message_thread_id?: number; + /** + * Identifier of the direct messages topic to which the message will be sent; required if the message is sent to a + * direct messages chat + */ + direct_messages_topic_id?: number; + /** + * Text of the message to be sent, 1-4096 characters after entities parsing + */ + text: string; + /** + * Mode for parsing entities in the message text. See formatting options for more details. + */ + parse_mode?: ParseMode; + /** + * A JSON-serialized list of special entities that appear in message text, which can be specified instead of + * parse_mode + */ + entities?: MessageEntity[]; + /** + * Link preview generation options for the message + */ + link_preview_options?: LinkPreviewOptions; + /** + * Sends the message silently. Users will receive a notification with no sound. + */ + disable_notification?: boolean; + /** + * Protects the contents of the sent message from forwarding and saving + */ + protect_content?: boolean; + /** + * Pass True to allow up to 1000 messages per second, ignoring + * [broadcasting limits](https://core.telegram.org/bots/faq#how-can-i-message-all-of-my-bot-39s-subscribers-at-once) + * for a fee of 0.1 Telegram Stars per message. The relevant Stars will be withdrawn from the bot's balance + */ + allow_paid_broadcast?: boolean; + /** + * Unique identifier of the message effect to be added to the message; for private chats only + */ + message_effect_id?: string; + /** + * A JSON-serialized object containing the parameters of the suggested post to send; for direct messages chats only. + * If the message is sent as a reply to another suggested post, then that suggested post is automatically declined. + */ + suggested_post_parameters?: SuggestedPostParameters; + /** + * Description of the message to reply to + */ + reply_parameters?: ReplyParameters; + /** + * Additional interface options. A JSON-serialized object for an inline keyboard, custom reply keyboard, + * instructions to remove a reply keyboard or to force a reply from the user + */ + reply_markup?: InlineKeyboardMarkup | ReplyKeyboardMarkup | ReplyKeyboardRemove | ForceReply; +}; \ No newline at end of file diff --git a/src/util/utils.ts b/src/util/utils.ts index 73fa3a0..d9bd0d3 100644 --- a/src/util/utils.ts +++ b/src/util/utils.ts @@ -11,6 +11,7 @@ import { Message, ParseMode, PhotoSize, + TelegramBot, User } from "typescript-telegram-bot-api"; import {Environment} from "../common/environment"; @@ -30,7 +31,7 @@ import {MessageStore} from "../common/message-store"; import {SystemInfo} from "../commands/system-info"; import {PrefixResponse} from "../commands/prefix-response"; import {OllamaChat} from "../commands/ollama-chat"; -import {getYouTubeVideoId} from "./ytdl"; +import {getYouTubeVideoId, getYouTubeVideoInfo, isVideoExists} from "./ytdl"; import {YouTubeDownload} from "../commands/youtube-download"; import {ChatCommand} from "../base/chat-command"; import {WebSearchResponse} from "../model/web-search-response"; @@ -43,6 +44,11 @@ import {OllamaGetModel} from "../commands/ollama-get-model"; import {GeminiGetModel} from "../commands/gemini-get-model"; import {MistralGetModel} from "../commands/mistral-get-model"; import {OpenAIGetModel} from "../commands/openai-get-model"; +import {SendOptions} from "../model/send-options"; +import {EditOptions} from "../model/edit-options"; +import VideoInfo from "youtubei.js/dist/src/parser/youtube/VideoInfo"; +import {DownloadYtVideo} from "../callback_commands/download-yt-video"; +import {TryAgain} from "../callback_commands/try-again"; export const ignore = () => { }; @@ -54,7 +60,7 @@ export const ignoreIfNotChanged = (e: Error | TelegramError) => { }; export const ignoreIfMarkupFailed = (e: Error | TelegramError) => { - if (!(e instanceof TelegramError && e?.response?.description?.startsWith("Bad Request: can't parse entities"))) { + if (!isMarkupFailed(e)) { throw e; } }; @@ -67,6 +73,18 @@ export const errorPlaceholder = async (msg: Message) => { await sendErrorPlaceholder(msg).catch(logError); }; +export const isMarkupFailed = (e: Error | TelegramError) => { + return TelegramBot.isTelegramError(e) && e?.response?.description?.startsWith("Bad Request: can't parse entities"); +}; + +export const isTooManyRequests = (e: Error | TelegramError) => { + return TelegramBot.isTelegramError(e) && e.response.description.includes("Too Many Requests"); +}; + +export const isMessageTooLong = (e: Error | TelegramError) => { + return TelegramBot.isTelegramError(e) && e.response.description.includes("MESSAGE_TOO_LONG"); +}; + export function searchChatCommand( commands: Command[], text: string, @@ -117,7 +135,7 @@ export async function checkRequirements(cmd: Command | CallbackCommand | null, m const cbId = cb?.id; const chatId = msg?.chat?.id || cb?.message?.chat?.id || -1; - const messageId = msg?.message_id || cb?.message?.message_id || -1; + const messageId = msg?.message_id || (cb && cb.message && "reply_to_message" in cb.message ? cb.message.reply_to_message.message_id : null) || -1; const fromId = msg?.from?.id || cb?.from?.id || -1; const chatType = msg?.chat?.type || cb?.message?.chat?.type || null; @@ -196,11 +214,8 @@ export async function checkRequirements(cmd: Command | CallbackCommand | null, m if (reqs.isRequiresSameUser()) { let originalFromId: number | null; try { - const queryMessage = await MessageStore.get(chatId, messageId); - if (queryMessage && queryMessage.replyToMessageId) { - const originalMessage = await MessageStore.get(chatId, queryMessage.replyToMessageId); - originalFromId = originalMessage?.fromId; - } + const originalMessage = await MessageStore.get(chatId, messageId); + originalFromId = originalMessage?.fromId; } catch (e) { logError(e); originalFromId = null; @@ -239,92 +254,87 @@ export async function findAndExecuteCallbackCommand(commands: CallbackCommand[], return true; } -export async function editMessageText(chatId: number, messageId: number, messageText: string, parseMode?: ParseMode, replyMarkup?: InlineKeyboardMarkup): Promise { - if (messageText.trim().length === 0) return Promise.resolve(); +export async function oldEditMessageText(chatId: number, messageId: number, messageText: string, parseMode?: ParseMode, replyMarkup?: InlineKeyboardMarkup): Promise { + return editMessageText({ + chat_id: chatId, + message_id: messageId, + text: messageText, + parse_mode: parseMode, + reply_markup: replyMarkup, + link_preview_options: {is_disabled: true} + }); +} + +export async function editMessageText(options: EditOptions) { + if (options.text.trim().length === 0) return Promise.resolve(false); + try { - await bot.editMessageText({ - chat_id: chatId, - message_id: messageId, - text: messageText, - parse_mode: parseMode, - link_preview_options: { - is_disabled: true - }, - reply_markup: replyMarkup - }).catch(ignoreIfMarkupFailed); - return Promise.resolve(); + const message = await bot.editMessageText({ + chat_id: "message" in options ? options.message.chat.id : options.chat_id, + message_id: "message" in options ? options.message.message_id : options.message_id, + text: options.text, + parse_mode: options.parse_mode, + reply_markup: options.reply_markup, + link_preview_options: options.link_preview_options, + }); + return Promise.resolve(message); } catch (e) { logError(e); - if (e instanceof TelegramError && e.response.description.includes("Too Many Requests")) { + if (isMarkupFailed(e)) { + return Promise.resolve(true); + } else if (isTooManyRequests(e)) { const delay = Number(e.message.split("retry after ")[1]) || 30; setTimeout(() => { return Promise.resolve(); }, delay * 1000); - } else if (e instanceof TelegramError && e.response.description.includes("MESSAGE_TOO_LONG")) { - return Promise.reject(e); } else { - return Promise.resolve(); + return Promise.reject(e); } } } -export type SendOptions = { - chat_id?: number; - message?: Message, - message_id?: number; - text: string, - parse_mode?: ParseMode, - disableLinkPreview?: boolean -}; - export async function oldSendMessage(message: Message, text: string, parseMode?: ParseMode): Promise { - const response = await bot.sendMessage({ - chat_id: message.chat.id, + return sendMessage({ + message: message, text: text, parse_mode: parseMode }); - - return Promise.resolve(response); } export async function sendMessage(options: SendOptions): Promise { const response = await bot.sendMessage({ - chat_id: options.chat_id ?? options.message?.chat?.id, + chat_id: "message" in options ? options.message.chat.id : options.chat_id, text: options.text, parse_mode: options.parse_mode, - link_preview_options: { - is_disabled: options.disableLinkPreview - } - }); - - return Promise.resolve(response); -} - -export async function replyToMessage(options: SendOptions): Promise { - const response = await bot.sendMessage({ - chat_id: options.chat_id ?? options.message?.chat?.id, - text: options.text, - parse_mode: options.parse_mode, - reply_parameters: { - message_id: options.message_id || options.message?.message_id - }, - link_preview_options: { - is_disabled: options.disableLinkPreview - } + link_preview_options: options.link_preview_options, + reply_markup: options.reply_markup, }); return Promise.resolve(response); } export async function oldReplyToMessage(message: Message, text: string, parseMode?: ParseMode): Promise { - const response = await bot.sendMessage({ - chat_id: message.chat.id, + return replyToMessage({ + message: message, text: text, + parse_mode: parseMode + }); +} + +export async function replyToMessage(options: SendOptions): Promise { + if (!("message" in options) && !options.message_id) { + return Promise.reject("for reply there must be message or message_id"); + } + + const response = await bot.sendMessage({ + chat_id: "message" in options ? options.message.chat.id : options.chat_id, + text: options.text, + parse_mode: options.parse_mode, reply_parameters: { - message_id: message.message_id + message_id: "message" in options ? options.message.message_id : options.message_id }, - parse_mode: parseMode, + link_preview_options: options.link_preview_options }); return Promise.resolve(response); @@ -1200,27 +1210,8 @@ export async function processNewMessage(msg: Message): Promise { } const textToCheck = startsWithPrefix ? messageWithoutPrefix : cmdText; - if (msg.entities) { - const urlEntities = msg.entities.filter(e => e.type === "url"); - if (urlEntities.length) { - for (const e of urlEntities) { - const url = msg.text.substring(e.offset, e.offset + e.length); - // TODO: 31/01/2026, Danil Nikolaev: implement proper checking - try { - getYouTubeVideoId(url); - - const yt = commands.find(e => e instanceof YouTubeDownload); - if (await checkRequirements(yt, msg)) { - await yt.downloadYouTubeVideo(msg, url); - } - return; - } catch (e) { - logError(e); - } - } - } - } + if (Environment.PROCESS_LINKS && await processYouTubeLink(msg, getFirstLink(msg))) return; if (!startsWithPrefix && msg.chat.type !== "private") return; if (msg.chat.type === "private" && !Environment.ADMIN_IDS.has(msg.chat.id)) return; @@ -1244,6 +1235,104 @@ export async function processNewMessage(msg: Message): Promise { } } +function getFirstLink(msg: Message): string | null { + if (msg.entities) { + const urlEntities = msg.entities.filter(e => e.type === "url"); + if (urlEntities.length) { + const e = urlEntities[0]; + return msg.text.substring(e.offset, e.offset + e.length); + } + } + + return null; +} + +export async function processYouTubeLink(msg: Message, url: string): Promise { + if (!url) return false; + try { + const videoId = getYouTubeVideoId(url); + const yt = commands.find(e => e instanceof YouTubeDownload); + + if (await checkRequirements(yt, msg)) { + const waitMessage = msg.from.id === botUser.id ? msg : await replyToMessage({ + message: msg, + text: "⏳ Ищу информацию о видео..." + }); + + if (msg.from.id === botUser.id) { + await editMessageText({message: msg, text: "⏳ Ищу информацию о видео..."}); + } + + let videoInfo: VideoInfo | null = null; + let ytError: string = null; + + try { + videoInfo = await getYouTubeVideoInfo(videoId); + } catch (e) { + logError(e); + + if ("version" in e) { + ytError = e.message; + } + } + + console.log("VIDEO_INFO", videoInfo); + + let text: string = null; + + const inCache = isVideoExists({videoId: videoId}); + + const duration = videoInfo?.basic_info?.duration || null; + const canDownload = inCache || duration && duration <= 300; + + if (videoInfo) { + text = "Видео с YouTube\n\n" + + `Название: ${videoInfo.basic_info?.title}\n` + + `Автор: ${videoInfo.secondary_info?.owner?.author?.name}\n` + + `Длительность: ${duration} сек.`; + + if (!canDownload) { + text += `\n\nВидео слишком длинное (${duration} сек. > 300 сек.)`; + } + } else if (!ytError) { + text = "Информация о видео не найдена"; + } + + const errorButInCache = !videoInfo && ytError && inCache; + if (errorButInCache) { + text = "Я не смогу получить информацию о видео, но нашёл его в кэше."; + } + + if (!text && ytError) { + await editMessageText({ + message: waitMessage, + text: Environment.errorText, + reply_markup: { + inline_keyboard: [[ + TryAgain.withData("/ytinfo " + url).asButton() + ]] + } + }); + } else { + await editMessageText({ + message: waitMessage, + text: text, + reply_markup: canDownload ? { + inline_keyboard: [[ + DownloadYtVideo.withData(inCache, "/ytdl " + videoId).asButton() + ]] + } : {inline_keyboard: []} + }); + } + } + return true; + } catch (e) { + logError(e); + } + + return false; +} + export async function processEditedMessage(msg: Message): Promise { console.log("Edited Message", msg); diff --git a/src/util/ytdl.ts b/src/util/ytdl.ts index 868747c..7965915 100644 --- a/src/util/ytdl.ts +++ b/src/util/ytdl.ts @@ -6,6 +6,21 @@ import Innertube, {Platform, Types} from "youtubei.js"; import {Readable} from "node:stream"; import {logError} from "./utils"; import {performFFmpeg} from "./ffmpeg"; +import VideoInfo from "youtubei.js/dist/src/parser/youtube/VideoInfo"; + +let innertube: Innertube | null = null; + +export async function getYT(): Promise { + if (innertube) { + return innertube; + } else { + innertube = await Innertube.create({ + generate_session_locally: true, + retrieve_player: true + }); + return innertube; + } +} export function getYouTubeVideoId(url: string): string { const regex = /(?:(?:youtube\.com|music\.youtube\.com)\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?|shorts|clip)\/|.*[?&]v=)|youtu\.be\/)([^"&?/\s]{11})/i; @@ -14,7 +29,34 @@ export function getYouTubeVideoId(url: string): string { return match[1]; } -export async function downloadVideoFromYouTube(url: string): Promise<{ +export async function getYouTubeVideoInfo(videoId: string): Promise { + try { + return (await getYT()).getInfo(videoId, {client: "ANDROID"}); + } catch (e) { + logError(e); + } +} + +export function isVideoExists(options: DownloadOptions): boolean { + const videoId = "videoId" in options ? options.videoId : getYouTubeVideoId(options.url); + const filePath = path.join(videoDir, `${videoId}.mp4`); + return fs.existsSync(filePath); +} + +export function getVideoFromCache(videoId: string): Buffer | null { + if (!isVideoExists({videoId: videoId})) return null; + + const filePath = path.join(videoDir, `${videoId}.mp4`); + return Buffer.from(fs.readFileSync(filePath)); +} + +export type DownloadOptions = { + url: string +} | { + videoId: string; +} + +export async function downloadVideoFromYouTube(options: DownloadOptions): Promise<{ time: number, exists?: boolean, buffer: Buffer | null @@ -23,7 +65,7 @@ export async function downloadVideoFromYouTube(url: string): Promise<{ let buffer: Buffer | null = null; try { - const videoId = getYouTubeVideoId(url); + const videoId = "videoId" in options ? options.videoId : getYouTubeVideoId(options.url); const filePath = path.join(videoDir, `${videoId}.mp4`); if (fs.existsSync(filePath)) { const buffer = Buffer.from(fs.readFileSync(filePath)); @@ -42,12 +84,11 @@ export async function downloadVideoFromYouTube(url: string): Promise<{ const code = `${data.output}\nreturn { ${properties.join(", ")} }`; return new Function(code)(); }; - const yt = await Innertube.create({ - generate_session_locally: true, - retrieve_player: true - }); + + const yt = await getYT(); const videoInfo = await yt.getInfo(videoId, {client: "ANDROID"}); + console.log("Video info", videoInfo); console.log(`Fetching metadata for: ${videoId}...`); @@ -119,7 +160,7 @@ export async function downloadVideoFromYouTube(url: string): Promise<{ const end = Date.now(); const diff = end - start; - console.log(`Video downloaded. URL: ${url}\ntook ${diff}ms`); + console.log(`Video downloaded.\ntook ${diff}ms`); return { time: diff,