feat: separate Ollama text/image/think models + add YouTube downloader
Support OLLAMA_IMAGE_MODEL and new OLLAMA_THINK_MODEL (both default to OLLAMA_MODEL) Add /ollamathink command; validate thinking capability; disable image-analysis flow in think mode Make /ollama-get-model show Text/Image/Think blocks only when models differ Add /ytdl (/youtube) command + auto-detect YouTube URLs in messages Cache downloaded videos to data/video and schedule daily cleanup Move photo storage from data/temp to data/photo; improve env boolean parsing via ifTrue Update deps (youtubei.js, puppeteer*) and TS config (allowJs, allowSyntheticDefaultImports)
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import {logError} from "./utils";
|
||||
import fs from "node:fs";
|
||||
import {videoDir} from "../index";
|
||||
import path from "node:path";
|
||||
|
||||
export function clearUpVideoFolder() {
|
||||
fs.readdir(videoDir, (err, files) => {
|
||||
if (err) {
|
||||
logError(err);
|
||||
return;
|
||||
}
|
||||
|
||||
const filenamesToDelete: string[] = [];
|
||||
|
||||
files.forEach((filename, index) => {
|
||||
fs.stat(path.join(videoDir, filename), (err, stats) => {
|
||||
if (err) {
|
||||
logError(err);
|
||||
} else {
|
||||
const then = stats.mtime.getTime() / 1000;
|
||||
const now = Date.now() / 1000;
|
||||
const diff = Math.abs(now - then);
|
||||
const moreThanOneDay = diff >= 60 * 60 * 24;
|
||||
if (moreThanOneDay) {
|
||||
filenamesToDelete.push(filename);
|
||||
}
|
||||
|
||||
if (index === files.length - 1) {
|
||||
console.log("filenamesToDelete", filenamesToDelete);
|
||||
if (filenamesToDelete.length) {
|
||||
filenamesToDelete.forEach((filename) => {
|
||||
const fullPath = path.join(videoDir, filename);
|
||||
fs.rm(fullPath, logError);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
+50
-17
@@ -27,6 +27,8 @@ 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 {YouTubeDownload} from "../commands/youtube-download";
|
||||
|
||||
export const ignore = () => {
|
||||
};
|
||||
@@ -469,10 +471,14 @@ export function extractTextMessage(msg: Message | StoredMessage | string): strin
|
||||
export function cutPrefixes(msg: Message | StoredMessage | string): string {
|
||||
const prefixes = [
|
||||
Environment.BOT_PREFIX,
|
||||
`/ollamathink@${botUser.username}`,
|
||||
"/ollamathink",
|
||||
`/ollama@${botUser.username}`,
|
||||
"/ollama",
|
||||
`/gemini@${botUser.username}`,
|
||||
"/gemini",
|
||||
`/mistral@${botUser.username}`,
|
||||
"/mistral"
|
||||
"/mistral",
|
||||
];
|
||||
|
||||
const text = extractTextMessage(msg);
|
||||
@@ -510,7 +516,7 @@ export async function loadImagesIfExists(msg: Message | StoredMessage): Promise<
|
||||
|
||||
const maxSize = await mapPhotoSizeToMax(getPhotoMaxSize(msg.photo));
|
||||
if (maxSize) {
|
||||
const imagePath = path.join(Environment.DATA_PATH, "temp");
|
||||
const imagePath = path.join(Environment.DATA_PATH, "photo");
|
||||
if (!fs.existsSync(imagePath)) {
|
||||
fs.mkdirSync(imagePath);
|
||||
}
|
||||
@@ -539,7 +545,7 @@ export async function loadImagesIfExists(msg: Message | StoredMessage): Promise<
|
||||
export async function loadImagesFromFileIds(sizes: PhotoSize[]): Promise<string[] | null> {
|
||||
if (!sizes?.length) return null;
|
||||
|
||||
const dataPath = path.join(Environment.DATA_PATH, "temp");
|
||||
const dataPath = path.join(Environment.DATA_PATH, "photo");
|
||||
if (!fs.existsSync(dataPath)) {
|
||||
fs.mkdirSync(dataPath);
|
||||
}
|
||||
@@ -988,20 +994,25 @@ export async function mapPhotoSizeToMax(size: PhotoSize): Promise<PhotoMaxSize |
|
||||
};
|
||||
}
|
||||
|
||||
export async function imageToBase64(filePath: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.readFile(filePath, (err, data) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
const base64Image = Buffer.from(data).toString("base64");
|
||||
const dataUrl = `data:image/jpeg;base64,${base64Image}`;
|
||||
resolve(dataUrl);
|
||||
});
|
||||
});
|
||||
export async function imageToBase64(filePath: string, withMimeType: boolean = false): Promise<string | null> {
|
||||
if (!fs.existsSync(filePath)) return null;
|
||||
|
||||
try {
|
||||
const file = fs.readFileSync(filePath);
|
||||
const base64 = Buffer.from(file).toString("base64");
|
||||
if (withMimeType) {
|
||||
return `data:image/jpeg;base64,${base64}`;
|
||||
}
|
||||
|
||||
return base64;
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function ifTrue(exp?: never): boolean {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function ifTrue(exp?: any): boolean {
|
||||
if (!exp) return false;
|
||||
|
||||
return ["true", "t", "y", 1, "1"].includes(exp);
|
||||
@@ -1093,12 +1104,34 @@ export async function processNewMessage(msg: Message) {
|
||||
return;
|
||||
}
|
||||
|
||||
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 = chatCommands.find(e => e instanceof YouTubeDownload);
|
||||
if (await checkRequirements(yt, msg)) {
|
||||
await yt.downloadYouTubeVideo(msg, url);
|
||||
}
|
||||
return;
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!startsWithPrefix && msg.chat.type !== "private") return;
|
||||
if (msg.chat.type === "private" && !Environment.ADMIN_IDS.has(msg.chat.id)) return;
|
||||
|
||||
const chat = chatCommands.find(e => e instanceof OllamaChat);
|
||||
if (await checkRequirements(chat, msg)) {
|
||||
await chat.executeOllama(msg, startsWithPrefix ? messageWithoutPrefix : cmdText);
|
||||
await chat.executeOllama(msg, textToCheck);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1121,5 +1154,5 @@ async function processAlbum(groupId: string): Promise<string[]> {
|
||||
}
|
||||
|
||||
export function photoPathByUniqueId(uniqueId: string): string {
|
||||
return path.join(Environment.DATA_PATH, "temp", uniqueId + ".jpg");
|
||||
return path.join(Environment.DATA_PATH, "photo", uniqueId + ".jpg");
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import Innertube, {Platform, Types, Utils} from "youtubei.js";
|
||||
import fs, {createWriteStream} from "node:fs";
|
||||
import path from "node:path";
|
||||
import {Environment} from "../common/environment";
|
||||
|
||||
export function getYouTubeVideoId(url: string): string {
|
||||
const regex = /(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?|shorts)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/\s]{11})/i;
|
||||
const match = url.match(regex);
|
||||
if (!match || !match[1]) throw new Error("Invalid YouTube or Shorts URL");
|
||||
return match[1];
|
||||
}
|
||||
|
||||
export async function downloadVideoFromYouTube(url: string, targetQuality: string = "720p"): Promise<{
|
||||
time: number,
|
||||
exists?: boolean,
|
||||
buffer: Buffer | null
|
||||
}> {
|
||||
const start = Date.now();
|
||||
let buffer: Buffer | null = null;
|
||||
|
||||
try {
|
||||
const videoId = getYouTubeVideoId(url);
|
||||
const videoFolder = path.join(Environment.DATA_PATH, "video");
|
||||
if (!fs.existsSync(videoFolder)) {
|
||||
fs.mkdirSync(videoFolder);
|
||||
}
|
||||
|
||||
const filePath = path.join(videoFolder, `${videoId}.mp4`);
|
||||
if (fs.existsSync(filePath)) {
|
||||
const buffer = Buffer.from(fs.readFileSync(filePath));
|
||||
return {
|
||||
time: Date.now() - start,
|
||||
exists: true,
|
||||
buffer: buffer
|
||||
};
|
||||
}
|
||||
|
||||
Platform.shim.eval = async (data: Types.BuildScriptResult, env: Record<string, Types.VMPrimative>) => {
|
||||
const properties = [];
|
||||
if (env.n) properties.push(`n: exportedVars.nFunction("${env.n}")`);
|
||||
if (env.sig) properties.push(`sig: exportedVars.sigFunction("${env.sig}")`);
|
||||
|
||||
const code = `${data.output}\nreturn { ${properties.join(", ")} }`;
|
||||
return new Function(code)();
|
||||
};
|
||||
const yt = await Innertube.create({
|
||||
generate_session_locally: true,
|
||||
retrieve_player: true
|
||||
});
|
||||
|
||||
const info = await yt.getInfo(videoId);
|
||||
|
||||
console.log(`Fetching metadata for: ${videoId}...`);
|
||||
|
||||
const format = info.streaming_data?.formats.find(f => f.quality_label === targetQuality)
|
||||
|| info.streaming_data?.adaptive_formats.find(f => f.quality_label === targetQuality);
|
||||
|
||||
if (!format) {
|
||||
console.log(`Quality ${targetQuality} not found. Falling back to best available.`);
|
||||
}
|
||||
|
||||
const stream = await yt.download(videoId, {
|
||||
type: "video+audio",
|
||||
quality: "best",
|
||||
format: "mp4"
|
||||
});
|
||||
|
||||
const file = createWriteStream(filePath);
|
||||
|
||||
console.log("Downloading...");
|
||||
|
||||
for await (const chunk of Utils.streamToIterable(stream)) {
|
||||
file.write(chunk);
|
||||
}
|
||||
|
||||
file.end();
|
||||
|
||||
buffer = fs.readFileSync(filePath);
|
||||
console.log(`✅ Saved to ${videoId}.mp4`);
|
||||
} catch (error) {
|
||||
console.error("❌ Download failed:", error instanceof Error ? error.message : error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const end = Date.now();
|
||||
const diff = end - start;
|
||||
console.log(`Video downloaded. URL: ${url}\ntook ${diff}ms`);
|
||||
|
||||
return {
|
||||
time: diff,
|
||||
buffer: buffer,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user