From a865c192bfe80c017d5e1db1dd5103b8d2abb564 Mon Sep 17 00:00:00 2001 From: fullgream Date: Wed, 15 Oct 2025 07:05:50 +0300 Subject: [PATCH] finish first revision of bot --- src/bot.js | 275 ++++++++++++++++++++++++++++++++++-------- src/download.js | 66 ++++++++-- src/package-lock.json | 20 ++- src/package.json | 1 + 4 files changed, 301 insertions(+), 61 deletions(-) diff --git a/src/bot.js b/src/bot.js index 980cf0d..dd804d1 100644 --- a/src/bot.js +++ b/src/bot.js @@ -1,67 +1,244 @@ const TelegramBot = require("node-telegram-bot-api"); const fs = require("fs"); const DownloadVideo = require("./download"); +const https = require("https"); + +async function getImage(url) { + return new Promise((resolve, reject) => { + https + .get(url, (res) => { + if (res.statusCode !== 200) { + reject(new Error(`HTTP ${res.statusCode} при загрузке изображения`)); + return; + } + + const chunks = []; + res.on("data", (chunk) => chunks.push(chunk)); + res.on("end", () => resolve(Buffer.concat(chunks))); + }) + .on("error", reject); + }); +} const config = { - token: process.env.BOT_TOKEN, - admins: process.env.ADMIN_LIST?.split(/\s*,\s*/g).map(x => +x) ?? [], + token: process.env.BOT_TOKEN, + admins: process.env.ADMIN_LIST?.split(/\s*,\s*/g).map((x) => +x) ?? [], }; config.whitelist = [].concat( - process.env.WHITELIST?.split(/\s*,\s*/g).map(x => +x) ?? [], - config.admins, + process.env.WHITELIST?.split(/\s*,\s*/g).map((x) => +x) ?? [], + config.admins, ); config.whitelist = Object.keys( - Object.fromEntries(config.whitelist.map(x => [x, true])) -).map(x => +x); + Object.fromEntries(config.whitelist.map((x) => [x, true])), +).map((x) => +x); console.info(config); -config.admins = Object.fromEntries(config.admins.map(x => [x, true])); -config.whitelist = Object.fromEntries(config.whitelist.map(x => [x, true])); +config.admins = Object.fromEntries(config.admins.map((x) => [x, true])); +config.whitelist = Object.fromEntries(config.whitelist.map((x) => [x, true])); const ytLinkRegexp = /^https:\/\/www\.youtube\.com/m; + +class TgBotSender { + constructor(parent, bot, msg, memory = {}) { + this.parent = parent; + this.bot = bot; + this.msg = msg; + + this.memory = memory; + } + + async textWithMedia(data, reply_markup) { + await this.bot.editMessageMedia(data, { + chat_id: this.msg.chat.id, + message_id: this.msg.message_id, + reply_markup, + }); + } + + async text(text, reply_markup) { + await this.bot.editMessageText(text, { + chat_id: this.msg.chat.id, + message_id: this.msg.message_id, + reply_markup, + }); + } + + async download(video, quality = DownloadVideo.qualities.mp4_best) { + const { msg } = this.memory; + + const sent = await this.parent.send(msg, "Start download"); + const info = await video.getVideoInfo(); + await video.setupQuality(quality, false); + let cooldown = new Date(); + video + .download(({ percent }) => { + const now = new Date(); + if (now - cooldown <= 1500) return; + cooldown = new Date(); + sent.text(`Downloaded (${percent}%)`); + }) + .then((downloaded) => { + sent.text("Download complete"); + if (downloaded.format.type === "video") { + this.bot.sendVideo( + msg.chat.id, + downloaded.data, + { caption: info.title }, + { + filename: `downloaded.${downloaded.format.exc}`, + contentType: downloaded.format.mime, + }, + ); + } else if (downloaded.format.type === "audio") { + this.bot.sendAudio( + msg.chat.id, + downloaded.data, + { caption: info.title }, + { + filename: `downloaded.${downloaded.format.exc}`, + contentType: downloaded.format.mime, + }, + ); + } + }) + .catch((err) => { + //console.error(err.stack); + this.bot.sendMessage(msg.chat.id, "Download failed"); + }); + } +} + +const actions = new Object(); class TgBot { - constructor () { - this.bot = new TelegramBot(config.token, { - polling: true - }); - this.bot.on('text', m => this.newMessageHandler(m)); - } - - async newMessageHandler (msg) { - console.debug(msg); - if (msg.chat.id === msg.from.id) { - if (config.whitelist[msg.from.id]) { - if (ytLinkRegexp.test(msg.text)) { - const video = new DownloadVideo(msg.text); - // Download only video with best quality - this.download(msg, video); - } else { - this.bot.sendMessage(msg.chat.id, "Invalid youtube link"); - } - } - else { - this.bot.sendMessage(msg.chat.id, "Permission denied"); - } - } - } - - async download (msg, video, quality=DownloadVideo.qualities.mp4_best) { - this.bot.sendMessage(msg.chat.id, "Start download"); - await video.setupQuality(quality, false); - video.download() - .then(downloaded => { - if (/^video\//m.test(downloaded.format.mime)) { - this.bot.sendVideo(msg.chat.id, downloaded.data, { caption: "Download complete" }, { - filename: `downloaded.${downloaded.format.exc}`, - contentType: downloaded.format.mime - }); - } - }) - .catch(() => { - this.bot.sendMessage(msg.chat.id, "Download failed"); - }); - } + constructor() { + this.bot = new TelegramBot(config.token, { + polling: true, + }); + this.bot.on("text", (m) => this.newMessageHandler(m)); + this.bot.on("callback_query", (m) => this.cbHandler(m)); + } + + async cbHandler(query) { + const key = query.from.id + ":" + query.data; + //console.debug(query, actions[key], key); + actions[key]?.(); + } + + async send(msg, txt) { + const result = await this.bot.sendMessage(msg.chat.id, txt); + return new TgBotSender(this, this.bot, result, { msg }); + } + + async newMessageHandler(msg) { + if (msg.chat.id === msg.from.id) { + if (config.whitelist[msg.from.id]) { + if (ytLinkRegexp.test(msg.text)) { + const sent = await this.send(msg, "Wait.."); + const errHandler = (err) => { + //console.error(err.stack); + sent.text("Error. Invalid/Hidden video or Forbidden for download"); + }; + + try { + const video = new DownloadVideo(msg.text); + const info = await video.getVideoInfo(); + //console.debug(info); + // Download only video with best quality + const qualities = await video.qualities(); + //console.debug(qualities); + let keyboard = []; + let keyboardM = []; + let kbChunk = []; + let kbChunkM = []; + + for (let quality in qualities) { + const qualityItem = qualities[quality]; + if (qualityItem.type !== null) { + if (qualityItem.type === "audio") + kbChunkM.push({ + text: `🎧 ${qualityItem.exc}/${qualityItem.resolution}`, + callback_data: quality + "_" + msg.message_id, + }); + else if (qualityItem.type === "video") + kbChunk.push({ + text: `📹 ${qualityItem.exc}/${qualityItem.resolution}`, + callback_data: quality + "_" + msg.message_id, + }); + if (kbChunk.length >= 3) { + keyboard.push(kbChunk); + kbChunk = []; + } + if (kbChunkM.length >= 3) { + keyboardM.push(kbChunkM); + kbChunkM = []; + } + } + } + if (kbChunk.length > 0) keyboard.push(kbChunk); + if (kbChunkM.length > 0) keyboardM.push(kbChunkM); + keyboard = keyboard.concat(keyboardM); + //keyboard = keyboard.slice(-4); + //console.debug(keyboard); + //console.debug(info.title, info.thumbnail); + const thenHandler = () => { + for (let quality in qualities) { + const qualityItem = qualities[quality]; + const actKey = + msg.from.id + ":" + quality + "_" + msg.message_id; + actions[actKey] = () => { + sent + .download(video, qualityItem) + .then(() => {}) + .catch(errHandler); + }; + } + return Promise.resolve(null); + }; + await sent + .textWithMedia( + { + type: "photo", + media: info.thumbnail, + //media: { source: await getImage(info.thumbnail) }, + //media: "https://placekitten.com/500/350", + caption: info.title, + }, + { + inline_keyboard: keyboard, + }, + ) + .then(thenHandler) + .catch(() => { + sent + .textWithMedia( + { + type: "photo", + //media: info.thumbnail, + media: "https://placekitten.com/500/350", + caption: info.title, + }, + { + inline_keyboard: keyboard, + }, + ) + .then(thenHandler) + .catch(errHandler); + }); + + //this.download(msg, video); + //this.send(msg, "Start download"); + } catch (e) { + errHandler(e); + } + } else { + this.send(msg, "Invalid youtube link"); + } + } else { + this.send(msg, "Permission denied"); + } + } + } } const bot = new TgBot(); diff --git a/src/download.js b/src/download.js index 25722fc..724a48f 100644 --- a/src/download.js +++ b/src/download.js @@ -1,6 +1,9 @@ const { spawn } = require("child_process"); +const mime = require("mime"); +const path = require("path"); const formatsCache = new Map(); +const infoCache = new Map(); class DownloadVideo { static get qualities () { const q = { @@ -19,13 +22,56 @@ class DownloadVideo { this.quality = quality; if (!this.quality) this.quality = CLASS.qualities.default; } + + async getVideoInfo() { + const fromCache = infoCache.get(this.url); + if (fromCache) return fromCache; + + const result = await new Promise((resolve, reject) => { + const child = spawn("yt-dlp", ["--cookies", "cookies.txt", "-j", this.url]); + + let data = ""; + + child.stdout.on("data", chunk => { + data += chunk.toString(); + }); + + child.stderr.on("data", err => { + console.error("yt-dlp error:", err.toString()); + }); + + child.on("close", code => { + if (code !== 0) return reject(new Error("yt-dlp failed to get info")); + + try { + const info = JSON.parse(data); + + resolve({ + id: info.id, + title: info.title, + description: info.description, + uploader: info.uploader, + duration: info.duration, // секунды + thumbnail: + info.thumbnails?.[info.thumbnails.length - 1]?.url || + info.thumbnail, // лучшее превью + url: info.webpage_url, + }); + } catch (e) { + reject(e); + } + }); + }); + infoCache.set(this.url, result); + return result; + } async getFormats() { const fromCache = formatsCache.get(this.url); if (fromCache) return fromCache; const result = await new Promise((resolve, reject) => { - const child = spawn("yt-dlp", ["-j", this.url]); + const child = spawn("yt-dlp", ["--cookies", "cookies.txt", "-j", this.url]); let data = ""; child.stdout.on("data", (chunk) => (data += chunk.toString())); @@ -56,12 +102,17 @@ class DownloadVideo { } //const flag = f.format_id; + const mimeType = mime.getType(f.ext); + const type = (resolution === "audio-only" || /^audio\//m.test(mimeType)) ? "audio" : ( + /^video\//m.test(mimeType) ? "video" : null + ); q[f.ext + "_" + resolution] = { format_id: f.format_id, flag, // <- ключевой момент для yt-dlp exc: f.ext, // <- расширение resolution, filesize: f.filesize || null, + mime: mimeType, type: !mimeType ? null : type }; }); @@ -86,11 +137,9 @@ class DownloadVideo { async download(cb) { const data = await new Promise((resolve, reject) => { const params = [ - "-f", - this.quality.flag, - "-o", - "-", - this.url, + "--cookies", "cookies.txt", + "-f", this.quality.flag, + "-o", "-", this.url, ]; const child = spawn("yt-dlp", params); @@ -107,7 +156,7 @@ class DownloadVideo { const str = data.toString(); const match = str.match(/(\d+\.\d+)%/); if (match && cb) { - cb({ progress: parseFloat(match[1]), downloaded }); + cb({ percent: parseFloat(match[1]), downloaded }); } }); @@ -120,10 +169,11 @@ class DownloadVideo { }); }); - return { + const result = { format: this.quality, data, }; + return result; } } diff --git a/src/package-lock.json b/src/package-lock.json index 9c8f3bc..7bee60f 100644 --- a/src/package-lock.json +++ b/src/package-lock.json @@ -5,6 +5,7 @@ "packages": { "": { "dependencies": { + "mime": "^3.0.0", "node-telegram-bot-api": "^0.66.0" } }, @@ -1243,14 +1244,14 @@ } }, "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", "bin": { "mime": "cli.js" }, "engines": { - "node": ">=4" + "node": ">=10.0.0" } }, "node_modules/mime-db": { @@ -1296,6 +1297,17 @@ "node": ">=0.12" } }, + "node_modules/node-telegram-bot-api/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/oauth-sign": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", diff --git a/src/package.json b/src/package.json index b5a766f..91c99fd 100644 --- a/src/package.json +++ b/src/package.json @@ -1,5 +1,6 @@ { "dependencies": { + "mime": "^3.0.0", "node-telegram-bot-api": "^0.66.0" } }